Compare commits

...

No commits in common. "604d4c724485ca8b5498b10ce6632a368c228d5d" and "e41901d12621b3c95e2d2aef50ddffc127ebdf26" have entirely different histories.

119 changed files with 234 additions and 23691 deletions

View File

@ -1,19 +0,0 @@
# Dependency
node_modules
dist
.svelte-kit
build
# Environment
.env
.env.*
!.env.example
# OS
.DS_Store
Thumbs.db
# Docker
Dockerfile
docker-compose.yml
.dockerignore

16
.gitignore vendored
View File

@ -1,16 +0,0 @@
/tmp
/out-tsc
/node_modules
**/node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/.pnp
.pnp.js
.vscode/*
.idea/*
**/.idea
**/.svelte-kit
**/dist

10
.idea/.gitignore vendored
View File

@ -1,10 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -1,382 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Prevent past bookings and updates across web and API&#10;&#10;- Add validation logic to block creating or updating reservations in the past.&#10;- Update date pickers to enforce minimum selectable date as today.&#10;- Extend i18n files (`en.json`, `es-ES.json`) with new error messages.&#10;- Refine UI to disable interaction with past time slots and display appropriate messages.">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" 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/dto/create-reservation.dto.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/reservations/dto/create-reservation.dto.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/reservations/dto/update-reservation.dto.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/reservations/dto/update-reservation.dto.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/reservations/reservations.controller.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/reservations/reservations.controller.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/reservations/reservations.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/reservations/reservations.service.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/BookingModal.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/BookingModal.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/en.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/en.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/es-ES.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/i18n/locales/es-ES.json" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="93" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="McpProjectServerCommands">
<commands />
<urls />
</component>
<component name="ProblemsViewState">
<option name="selectedTabId" value="AISelfReview" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 0,
&quot;fromUser&quot;: false
}</component>
<component name="ProjectId" id="3BXlUbAsiEmEvH0PacGIZgCuvpY" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;Docker.docker-compose.yml: Compose Deployment.executor&quot;: &quot;Run&quot;,
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;javascript.preferred.runtime.type.id&quot;: &quot;node&quot;,
&quot;junie.onboarding.icon.badge.shown&quot;: &quot;true&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_interpreter_path&quot;: &quot;/home/micosil/.nvm/versions/node/v22.21.1/bin/node&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;npm.api &gt; start:dev.executor&quot;: &quot;Run&quot;,
&quot;npm.web &gt; dev.executor&quot;: &quot;Run&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;settings.javascript.runtime&quot;,
&quot;to.speed.mode.migration.done&quot;: &quot;true&quot;,
&quot;ts.external.directory.path&quot;: &quot;/home/micosil/WebstormProjects/desker/node_modules/typescript/lib&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
},
&quot;keyToStringList&quot;: {
&quot;DatabaseDriversLRU&quot;: [
&quot;postgresql&quot;
]
}
}</component>
<component name="RunManager" selected="npm.web &gt; dev">
<configuration default="true" type="docker-deploy" factoryName="docker-compose.yml" temporary="true">
<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">
<deployment type="docker-compose.yml">
<settings>
<option name="sourceFilePath" value="docker-compose.yml" />
</settings>
</deployment>
<method v="2" />
</configuration>
<configuration name="api &gt; start:dev" type="js.build_tools.npm" temporary="true" nameIsGenerated="true">
<package-json value="$PROJECT_DIR$/apps/api/package.json" />
<command value="run" />
<scripts>
<script value="start:dev" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
<configuration name="web &gt; dev" type="js.build_tools.npm" temporary="true" nameIsGenerated="true">
<package-json value="$PROJECT_DIR$/apps/web/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="$USER_HOME$/.nvm/versions/node/v22.21.1/bin/node" />
<envs />
<method v="2" />
</configuration>
<recent_temporary>
<list>
<item itemvalue="npm.web &gt; dev" />
<item itemvalue="npm.api &gt; start:dev" />
<item itemvalue="Docker.docker-compose.yml: Compose Deployment" />
</list>
</recent_temporary>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-3bd3a6803838-JavaScript-WS-261.22158.274" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="" />
<created>1774643312599</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1774643312599</updated>
<workItem from="1774643313611" duration="74000" />
<workItem from="1774643422240" duration="5278000" />
<workItem from="1774706361502" duration="12417000" />
</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.">
<option name="closed" value="true" />
<created>1774652817905</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1774652817905</updated>
</task>
<task id="LOCAL-00002" summary="Remove compiled API files from version control&#10;&#10;- Deleted compiled JavaScript files, type definitions, and source maps from the `dist` folder.">
<option name="closed" value="true" />
<created>1774653909300</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1774653909300</updated>
</task>
<task id="LOCAL-00003" summary="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.">
<option name="closed" value="true" />
<created>1774654565964</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1774654565964</updated>
</task>
<task id="LOCAL-00004" summary="Add floorplan editor for admin layout management&#10;&#10;- Implement `FloorplanEditor.svelte` component for adding, editing, and positioning spaces.&#10;- Add backend support for floorplan image upload and batch updating space positions.&#10;- Update Prisma schema to include `planImageUrl` for floors.&#10;- Enable API proxy in Vite configuration for straightforward local development.&#10;- Refactor API calls to use a centralized `apiFetch` utility with token handling.">
<option name="closed" value="true" />
<created>1774707328435</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1774707328435</updated>
</task>
<task id="LOCAL-00005" summary="Add building-level dashboard and interactive floorplan viewer&#10;&#10;- Create `+page.svelte` for building-level dashboard with floor selection and layout preview.&#10;- Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates.&#10;- Extend `SpacesService` and API endpoints for building floors and floor details retrieval.&#10;- Update dashboard UI with cleaner building and floor presentation.&#10;- Improve loading states and error handling across dashboard and floor pages.">
<option name="closed" value="true" />
<created>1774707617715</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1774707617715</updated>
</task>
<task id="LOCAL-00006" summary="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`).">
<option name="closed" value="true" />
<created>1774708414077</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1774708414077</updated>
</task>
<task id="LOCAL-00007" summary="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.">
<option name="closed" value="true" />
<created>1774709613724</created>
<option name="number" value="00007" />
<option name="presentableId" value="LOCAL-00007" />
<option name="project" value="LOCAL" />
<updated>1774709613724</updated>
</task>
<task id="LOCAL-00008" summary="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.">
<option name="closed" value="true" />
<created>1774710366017</created>
<option name="number" value="00008" />
<option name="presentableId" value="LOCAL-00008" />
<option name="project" value="LOCAL" />
<updated>1774710366017</updated>
</task>
<task id="LOCAL-00009" summary="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.&#10;- Add `+page.svelte` for user registration using invite tokens.&#10;-">
<option name="closed" value="true" />
<created>1774710670712</created>
<option name="number" value="00009" />
<option name="presentableId" value="LOCAL-00009" />
<option name="project" value="LOCAL" />
<updated>1774710670712</updated>
</task>
<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>
<task id="LOCAL-00011" summary="Add building and floor management with admin editor modal&#10;&#10;- Implement `BuildingEditorModal.svelte` for creating, editing, and deleting buildings and floors.&#10;- Add `CreateBuildingDto`, `UpdateBuildingDto`, `CreateFloorDto`, and `UpdateFloorDto` for validation.&#10;- Extend `SpacesService` and `SpacesController` with endpoints for building and floor CRUD operations.&#10;- Update Prisma schema to include building descriptions and image URLs, and floor names.&#10;- Enhance dashboard with building management functionality for admin users.">
<option name="closed" value="true" />
<created>1774711356754</created>
<option name="number" value="00011" />
<option name="presentableId" value="LOCAL-00011" />
<option name="project" value="LOCAL" />
<updated>1774711356754</updated>
</task>
<task id="LOCAL-00012" summary="Enhance real-time space status updates and handling&#10;&#10;- Improve WebSocket connection reliability with updated transport options.&#10;- Introduce logic to prioritize real-time updates over initial data for live views.&#10;- Add logging for better debugging of WebSocket events and data propagation.&#10;- Update UI components (`SpaceCard`, `FloorplanViewer`) to avoid overwrites from stale data.&#10;- Refactor date handling for consistent behavior across components.">
<option name="closed" value="true" />
<created>1774711643921</created>
<option name="number" value="00012" />
<option name="presentableId" value="LOCAL-00012" />
<option name="project" value="LOCAL" />
<updated>1774711643921</updated>
</task>
<task id="LOCAL-00013" summary="Fix ownership checks and improve delete booking button UI&#10;&#10;- Update `isOwner` logic to account for nested user objects in reservation data.&#10;- Enhance delete button styling for better visibility and usability.">
<option name="closed" value="true" />
<created>1774711743510</created>
<option name="number" value="00013" />
<option name="presentableId" value="LOCAL-00013" />
<option name="project" value="LOCAL" />
<updated>1774711743510</updated>
</task>
<task id="LOCAL-00014" summary="Add organization management with logo support&#10;&#10;- Implement organization settings page (`+page.svelte`) for updating organization name and logo.&#10;- Create backend APIs for retrieving and updating organization details with role-based access control.&#10;- Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS.&#10;- Update Prisma schema to include `logoUrl` field for organizations.&#10;- Enhance navigation to dynamically display organization logo and name.">
<option name="closed" value="true" />
<created>1774712424451</created>
<option name="number" value="00014" />
<option name="presentableId" value="LOCAL-00014" />
<option name="project" value="LOCAL" />
<updated>1774712424451</updated>
</task>
<task id="LOCAL-00015" summary="Add assets module with file upload and retrieval support &#10;&#10;- Introduce `AssetsModule` with APIs for uploading and retrieving organization logos, building images, and floor plans. &#10;- Implement file validation for type and size (10MB max). &#10;- Add `resolveAssetUrl` utility for generating absolute asset URLs. &#10;- Enhance admin UI components to support image file uploads and previews. &#10;- Update Prisma schema to associate assets with organizations, buildings, and floors. &#10;- Modify existing update logic to clear references when external URLs are used.">
<option name="closed" value="true" />
<created>1774714222515</created>
<option name="number" value="00015" />
<option name="presentableId" value="LOCAL-00015" />
<option name="project" value="LOCAL" />
<updated>1774714222515</updated>
</task>
<task id="LOCAL-00016" summary="Add user profile management with avatar upload and update support&#10;&#10;- Create `+page.svelte` for managing user profiles, including name, avatar, and password updates.&#10;- Add `Avatar.svelte` component for rendering user avatars with Gravatar and fallback initials support.&#10;- Implement backend APIs for fetching and updating user profiles (`/users/profile`).&#10;- Add support for avatar uploads with file validation (max size: 5MB).&#10;- Update `FloorplanViewer.svelte` to display user names and avatars in reservations.&#10;- Introduce utilities for Gravatar URLs and user avatar management (`getGravatarUrl`, `getInitials`).">
<option name="closed" value="true" />
<created>1774714977147</created>
<option name="number" value="00016" />
<option name="presentableId" value="LOCAL-00016" />
<option name="project" value="LOCAL" />
<updated>1774714977147</updated>
</task>
<task id="LOCAL-00017" summary="Add i18n support with English and Spanish translations&#10;&#10;- Introduce `svelte-i18n` for internationalization.&#10;- Add English (`en.json`) and Spanish (`es-ES.json`) locale files.&#10;- Implement `setupI18n` function for initializing and managing locales.&#10;- Update project dependencies to include `svelte-i18n`.">
<option name="closed" value="true" />
<created>1774715653258</created>
<option name="number" value="00017" />
<option name="presentableId" value="LOCAL-00017" />
<option name="project" value="LOCAL" />
<updated>1774715653258</updated>
</task>
<task id="LOCAL-00018" summary="Update prompts to include progress indicator for documentation readability">
<option name="closed" value="true" />
<created>1774715728737</created>
<option name="number" value="00018" />
<option name="presentableId" value="LOCAL-00018" />
<option name="project" value="LOCAL" />
<updated>1774715728737</updated>
</task>
<task id="LOCAL-00019" summary="Add Docker deployment support for API and Web services&#10;&#10;- Add Dockerfiles for API and Web apps with multi-stage builds.&#10;- Configure `docker-compose.yml` to manage services (Postgres, API, Web, pgAdmin).&#10;- Add `.dockerignore` for optimized builds.&#10;- Include `DOCKER_SETUP.md` for deployment steps and guidance.">
<option name="closed" value="true" />
<created>1774715983233</created>
<option name="number" value="00019" />
<option name="presentableId" value="LOCAL-00019" />
<option name="project" value="LOCAL" />
<updated>1774715983233</updated>
</task>
<task id="LOCAL-00020" summary="Add rotation support for spaces in floorplan editor &#10;&#10;- Extend `Space` model and Prisma schema to include `rotation` property. &#10;- Update API services and DTOs to handle rotation data. &#10;- Add UI controls in `FloorplanEditor.svelte` for rotating spaces. &#10;- Refactor space rendering logic in both editor and viewer to support rotation.">
<option name="closed" value="true" />
<created>1774716452045</created>
<option name="number" value="00020" />
<option name="presentableId" value="LOCAL-00020" />
<option name="project" value="LOCAL" />
<updated>1774716452045</updated>
</task>
<task id="LOCAL-00021" summary="Add i18n integration and translations for improved localization&#10;&#10;- Extend English (`en.json`) and Spanish (`es-ES.json`) locale files with additional keys for buildings, bookings, reports, and user management. &#10;- Update Svelte components and pages to use `svelte-i18n` for dynamic translations across the app. &#10;- Refactor labels, titles, and error messages for multilingual support. &#10;- Improve date formatting based on locale preferences.">
<option name="closed" value="true" />
<created>1774717104130</created>
<option name="number" value="00021" />
<option name="presentableId" value="LOCAL-00021" />
<option name="project" value="LOCAL" />
<updated>1774717104130</updated>
</task>
<task id="LOCAL-00022" summary="Add seed service to initialize default admin and organization&#10;&#10;- Implement `SeedService` to create a default admin account and organization on app startup.&#10;- Update `PrismaModule` to include `SeedService` as a provider.&#10;- Remove seeded credentials display from login page.&#10;- Refactor existing seed script for consistency with the new service.">
<option name="closed" value="true" />
<created>1774717642991</created>
<option name="number" value="00022" />
<option name="presentableId" value="LOCAL-00022" />
<option name="project" value="LOCAL" />
<updated>1774717642991</updated>
</task>
<task id="LOCAL-00023" summary="Improve real-time space status handling and date-based resets&#10;&#10;- Add logic to clear real-time updates when the selected date changes.&#10;- Enhance space status updates to consider reservation time ranges for relevance.&#10;- Update relevant Svelte components (`SpaceCard`, `FloorplanViewer`) to handle date-sensitive real-time updates.&#10;- Update API to include reservation details in `space_status_changed` events for improved update precision.">
<option name="closed" value="true" />
<created>1774717937214</created>
<option name="number" value="00023" />
<option name="presentableId" value="LOCAL-00023" />
<option name="project" value="LOCAL" />
<updated>1774717937214</updated>
</task>
<task id="LOCAL-00024" summary="Add filtering for past and upcoming bookings in &quot;My Bookings&quot; page&#10;&#10;- Enhance UI to separate upcoming and past bookings with collapsible sections.&#10;- Add new i18n keys for handling booking filters (`en.json`, `es-ES.json`).&#10;- Update booking component logic to filter reservations based on time.&#10;- Introduce &quot;Show/Hide Past Bookings&quot; toggle for improved user experience.">
<option name="closed" value="true" />
<created>1774718091896</created>
<option name="number" value="00024" />
<option name="presentableId" value="LOCAL-00024" />
<option name="project" value="LOCAL" />
<updated>1774718091896</updated>
</task>
<task id="LOCAL-00025" summary="Refactor date picker components for improved accessibility and usability&#10;&#10;- Add `showPicker` event to trigger native date picker overlays.&#10;- Adjust z-index and positioning for hidden `input` elements to improve interaction.&#10;- Reorganize markup for consistent focus and selection behavior across components.">
<option name="closed" value="true" />
<created>1774718250404</created>
<option name="number" value="00025" />
<option name="presentableId" value="LOCAL-00025" />
<option name="project" value="LOCAL" />
<updated>1774718250404</updated>
</task>
<task id="LOCAL-00026" summary="Prevent past bookings and updates across web and API&#10;&#10;- Add validation logic to block creating or updating reservations in the past.&#10;- Update date pickers to enforce minimum selectable date as today.&#10;- Extend i18n files (`en.json`, `es-ES.json`) with new error messages.&#10;- Refine UI to disable interaction with past time slots and display appropriate messages.">
<option name="closed" value="true" />
<created>1774718397058</created>
<option name="number" value="00026" />
<option name="presentableId" value="LOCAL-00026" />
<option name="project" value="LOCAL" />
<updated>1774718397058</updated>
</task>
<option name="localTasksCounter" value="27" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="Add building-level dashboard and interactive floorplan viewer&#10;&#10;- Create `+page.svelte` for building-level dashboard with floor selection and layout preview.&#10;- Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates.&#10;- Extend `SpacesService` and API endpoints for building floors and floor details retrieval.&#10;- Update dashboard UI with cleaner building and floor presentation.&#10;- Improve loading states and error handling across dashboard and floor pages." />
<MESSAGE value="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`)." />
<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 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." />
<MESSAGE value="Add building and floor management with admin editor modal&#10;&#10;- Implement `BuildingEditorModal.svelte` for creating, editing, and deleting buildings and floors.&#10;- Add `CreateBuildingDto`, `UpdateBuildingDto`, `CreateFloorDto`, and `UpdateFloorDto` for validation.&#10;- Extend `SpacesService` and `SpacesController` with endpoints for building and floor CRUD operations.&#10;- Update Prisma schema to include building descriptions and image URLs, and floor names.&#10;- Enhance dashboard with building management functionality for admin users." />
<MESSAGE value="Enhance real-time space status updates and handling&#10;&#10;- Improve WebSocket connection reliability with updated transport options.&#10;- Introduce logic to prioritize real-time updates over initial data for live views.&#10;- Add logging for better debugging of WebSocket events and data propagation.&#10;- Update UI components (`SpaceCard`, `FloorplanViewer`) to avoid overwrites from stale data.&#10;- Refactor date handling for consistent behavior across components." />
<MESSAGE value="Fix ownership checks and improve delete booking button UI&#10;&#10;- Update `isOwner` logic to account for nested user objects in reservation data.&#10;- Enhance delete button styling for better visibility and usability." />
<MESSAGE value="Add organization management with logo support&#10;&#10;- Introduce `OrganizationsModule` with related logic in `AppModule`.&#10;- Update Prisma schema to include `logoUrl` field for organizations.&#10;- Enhance seeds to include default organization logo.&#10;- Update layout to dynamically display organization details, including logo and name.&#10;- Add UI support for organization-based navigation in admin routes." />
<MESSAGE value="Add organization management with logo support&#10;&#10;- Implement organization settings page (`+page.svelte`) for updating organization name and logo.&#10;- Create backend APIs for retrieving and updating organization details with role-based access control.&#10;- Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS.&#10;- Update Prisma schema to include `logoUrl` field for organizations.&#10;- Enhance navigation to dynamically display organization logo and name." />
<MESSAGE value="Add assets module with file upload and retrieval support &#10;&#10;- Introduce `AssetsModule` with APIs for uploading and retrieving organization logos, building images, and floor plans. &#10;- Implement file validation for type and size (10MB max). &#10;- Add `resolveAssetUrl` utility for generating absolute asset URLs. &#10;- Enhance admin UI components to support image file uploads and previews. &#10;- Update Prisma schema to associate assets with organizations, buildings, and floors. &#10;- Modify existing update logic to clear references when external URLs are used." />
<MESSAGE value="Add user profile management with avatar upload and update support&#10;&#10;- Create `+page.svelte` for managing user profiles, including name, avatar, and password updates.&#10;- Add `Avatar.svelte` component for rendering user avatars with Gravatar and fallback initials support.&#10;- Implement backend APIs for fetching and updating user profiles (`/users/profile`).&#10;- Add support for avatar uploads with file validation (max size: 5MB).&#10;- Update `FloorplanViewer.svelte` to display user names and avatars in reservations.&#10;- Introduce utilities for Gravatar URLs and user avatar management (`getGravatarUrl`, `getInitials`)." />
<MESSAGE value="Add i18n support with English and Spanish translations&#10;&#10;- Introduce `svelte-i18n` for internationalization.&#10;- Add English (`en.json`) and Spanish (`es-ES.json`) locale files.&#10;- Implement `setupI18n` function for initializing and managing locales.&#10;- Update project dependencies to include `svelte-i18n`." />
<MESSAGE value="Update prompts to include progress indicator for documentation readability" />
<MESSAGE value="Add Docker deployment support for API and Web services&#10;&#10;- Add Dockerfiles for API and Web apps with multi-stage builds.&#10;- Configure `docker-compose.yml` to manage services (Postgres, API, Web, pgAdmin).&#10;- Add `.dockerignore` for optimized builds.&#10;- Include `DOCKER_SETUP.md` for deployment steps and guidance." />
<MESSAGE value="Add rotation support for spaces in floorplan editor &#10;&#10;- Extend `Space` model and Prisma schema to include `rotation` property. &#10;- Update API services and DTOs to handle rotation data. &#10;- Add UI controls in `FloorplanEditor.svelte` for rotating spaces. &#10;- Refactor space rendering logic in both editor and viewer to support rotation." />
<MESSAGE value="Add i18n integration and translations for improved localization&#10;&#10;- Extend English (`en.json`) and Spanish (`es-ES.json`) locale files with additional keys for buildings, bookings, reports, and user management. &#10;- Update Svelte components and pages to use `svelte-i18n` for dynamic translations across the app. &#10;- Refactor labels, titles, and error messages for multilingual support. &#10;- Improve date formatting based on locale preferences." />
<MESSAGE value="Add seed service to initialize default admin and organization&#10;&#10;- Implement `SeedService` to create a default admin account and organization on app startup.&#10;- Update `PrismaModule` to include `SeedService` as a provider.&#10;- Remove seeded credentials display from login page.&#10;- Refactor existing seed script for consistency with the new service." />
<MESSAGE value="Improve real-time space status handling and date-based resets&#10;&#10;- Add logic to clear real-time updates when the selected date changes.&#10;- Enhance space status updates to consider reservation time ranges for relevance.&#10;- Update relevant Svelte components (`SpaceCard`, `FloorplanViewer`) to handle date-sensitive real-time updates.&#10;- Update API to include reservation details in `space_status_changed` events for improved update precision." />
<MESSAGE value="Add past and upcoming reservations filtering in My Bookings&#10;&#10;- Introduce filters to separate and toggle display of past and upcoming reservations.&#10;- Enhance localization with new translations for filter-related actions in English and Spanish.&#10;- Update My Bookings UI to dynamically display reservations based on their timeline.&#10;- Improve reservation display logic using `isPast` from `date-fns`." />
<MESSAGE value="Add filtering for past and upcoming bookings in &quot;My Bookings&quot; page&#10;&#10;- Enhance UI to separate upcoming and past bookings with collapsible sections.&#10;- Add new i18n keys for handling booking filters (`en.json`, `es-ES.json`).&#10;- Update booking component logic to filter reservations based on time.&#10;- Introduce &quot;Show/Hide Past Bookings&quot; toggle for improved user experience." />
<MESSAGE value="Refactor date picker components for improved accessibility and usability&#10;&#10;- Add `showPicker` event to trigger native date picker overlays.&#10;- Adjust z-index and positioning for hidden `input` elements to improve interaction.&#10;- Reorganize markup for consistent focus and selection behavior across components." />
<MESSAGE value="Prevent past bookings and updates across web and API&#10;&#10;- Add validation logic to block creating or updating reservations in the past.&#10;- Update date pickers to enforce minimum selectable date as today.&#10;- Extend i18n files (`en.json`, `es-ES.json`) with new error messages.&#10;- Refine UI to disable interaction with past time slots and display appropriate messages." />
<option name="LAST_COMMIT_MESSAGE" value="Prevent past bookings and updates across web and API&#10;&#10;- Add validation logic to block creating or updating reservations in the past.&#10;- Update date pickers to enforce minimum selectable date as today.&#10;- Extend i18n files (`en.json`, `es-ES.json`) with new error messages.&#10;- Refine UI to disable interaction with past time slots and display appropriate messages." />
</component>
</project>

View File

@ -1,59 +0,0 @@
# Docker Setup Guide
This guide describes how to deploy the application using Docker and Docker Compose.
## Prerequisites
- Docker (20.10+)
- Docker Compose (v2.0+)
## Environment Variables
Create a `.env` file in the root directory. The following variables are supported (with their default values shown):
| Variable | Description | Default |
|----------|-------------|---------|
| `DB_USER` | PostgreSQL username | `postgres` |
| `DB_PASSWORD` | PostgreSQL password | `postgres` |
| `DB_NAME` | PostgreSQL database name | `hotdesking` |
| `DB_PORT_EXTERNAL` | External port for PostgreSQL | `5433` |
| `API_PORT_EXTERNAL` | External port for the API | `3000` |
| `WEB_PORT_EXTERNAL` | External port for the Web app | `5173` |
| `JWT_SECRET` | Secret key for JWT signing | `super-secret-change-me` |
| `WEB_ORIGIN` | The origin of the web app (required by SvelteKit) | `http://localhost:5173` |
| `VITE_API_BASE_URL` | The URL of the API (for the web app) | `http://localhost:3000` |
## Deployment Steps
1. **Build and start the containers:**
```bash
docker-compose up -d --build
```
2. **Run Database Migrations:**
Since the API uses Prisma, you need to run migrations to set up the database schema:
```bash
docker exec -it hotdesking_api npx prisma migrate deploy
```
3. **(Optional) Seed the Database:**
If you have a seed script defined in `apps/api/prisma/seed.ts`:
```bash
docker exec -it hotdesking_api npx prisma db seed
```
4. **Access the Applications:**
- Web App: [http://localhost:5173](http://localhost:5173)
- API: [http://localhost:3000](http://localhost:3000)
- pgAdmin: [http://localhost:5051](http://localhost:5051) (Login: `admin@local.dev` / `admin`)
## Development with Docker
To watch logs:
```bash
docker-compose logs -f
```
To stop the applications:
```bash
docker-compose down
```

232
LICENSE Normal file
View File

@ -0,0 +1,232 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
desker
Copyright (C) 2026 micosil
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
desker Copyright (C) 2026 micosil
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <http://www.gnu.org/philosophy/why-not-lgpl.html>.

2
README.md Normal file
View File

@ -0,0 +1,2 @@
# desker

View File

@ -1,2 +0,0 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/hotdesking"
PORT=3000

View File

@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@ -1,48 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy root configs
COPY package*.json ./
COPY tsconfig.json ./
COPY tsconfig.base.json ./
# Copy packages
COPY apps/api/package*.json ./apps/api/
COPY shared/package*.json ./shared/
# Install dependencies (using npm ci for better builds)
RUN npm ci
# Copy source code
COPY apps/api ./apps/api
COPY shared ./shared
# Generate Prisma client
WORKDIR /app/apps/api
RUN npx prisma generate
# Build the app
RUN npm run build
# ---
FROM node:20-alpine AS runner
WORKDIR /app
# Copy build artifacts and necessary files
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=builder /app/apps/api/dist ./apps/api/dist
COPY --from=builder /app/apps/api/package.json ./apps/api/package.json
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
COPY --from=builder /app/shared/dist ./shared/dist
COPY --from=builder /app/shared/package.json ./shared/package.json
EXPOSE 3000
ENV PORT=3000
ENV NODE_ENV=production
WORKDIR /app/apps/api
CMD ["node", "dist/main"]

View File

@ -1,98 +0,0 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

View File

@ -1,35 +0,0 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

View File

@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@ -1,91 +0,0 @@
{
"name": "api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^10.4.22",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^10.4.22",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.4.22",
"@nestjs/platform-socket.io": "^10.4.22",
"@nestjs/websockets": "^10.4.22",
"@prisma/client": "^6.4.0",
"@types/bcrypt": "^6.0.0",
"@types/passport-jwt": "^4.0.1",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"prisma": "^6.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"socket.io": "^4.8.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.22",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/multer": "^2.1.0",
"@types/node": "^22.19.15",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.3.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.4.6",
"ts-loader": "^9.5.4",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}

View File

@ -1,79 +0,0 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'SUPERVISOR', 'EMPLOYEE');
-- CreateEnum
CREATE TYPE "SpaceType" AS ENUM ('DESK', 'MEETING_ROOM', 'AMENITY');
-- CreateTable
CREATE TABLE "Organization" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Building" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Building_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Floor" (
"id" TEXT NOT NULL,
"number" INTEGER NOT NULL,
"buildingId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Floor_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Space" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "SpaceType" NOT NULL,
"floorId" TEXT NOT NULL,
"x" DOUBLE PRECISION,
"y" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Space_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'EMPLOYEE',
"organizationId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- AddForeignKey
ALTER TABLE "Building" ADD CONSTRAINT "Building_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Floor" ADD CONSTRAINT "Floor_buildingId_fkey" FOREIGN KEY ("buildingId") REFERENCES "Building"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Space" ADD CONSTRAINT "Space_floorId_fkey" FOREIGN KEY ("floorId") REFERENCES "Floor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -1,118 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Organization {
id String @id @default(uuid())
name String
language String @default("en")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
logoUrl String?
logoId String? @unique
buildings Building[]
logo Asset? @relation(fields: [logoId], references: [id])
users User[]
}
model Building {
id String @id @default(uuid())
name String
organizationId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
description String?
imageUrl String?
imageId String? @unique
image Asset? @relation(fields: [imageId], references: [id])
organization Organization @relation(fields: [organizationId], references: [id])
floors Floor[]
}
model Floor {
id String @id @default(uuid())
number Int
buildingId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
planImageUrl String?
name String?
planImageId String? @unique
building Building @relation(fields: [buildingId], references: [id])
planImage Asset? @relation(fields: [planImageId], references: [id])
spaces Space[]
}
model Asset {
id String @id @default(uuid())
data Bytes
mimeType String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
building Building?
floor Floor?
organization Organization?
user User?
}
model Space {
id String @id @default(uuid())
name String
type SpaceType
floorId String
x Float?
y Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
height Float?
width Float?
rotation Float? @default(0)
reservations Reservation[]
floor Floor @relation(fields: [floorId], references: [id])
}
model User {
id String @id @default(uuid())
email String @unique
password String
name String?
avatarUrl String?
avatarId String? @unique
role Role @default(EMPLOYEE)
organizationId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
reservations Reservation[]
organization Organization @relation(fields: [organizationId], references: [id])
avatar Asset? @relation(fields: [avatarId], references: [id])
}
model Reservation {
id String @id @default(uuid())
startTime DateTime
endTime DateTime
userId String?
spaceId String
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
space Space @relation(fields: [spaceId], references: [id])
user User? @relation(fields: [userId], references: [id])
}
enum Role {
ADMIN
SUPERVISOR
EMPLOYEE
}
enum SpaceType {
DESK
MEETING_ROOM
AMENITY
}

View File

@ -1,72 +0,0 @@
import { PrismaClient, Role, SpaceType } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
const hashedPassword = await bcrypt.hash('admin123', 10);
const org = await prisma.organization.upsert({
where: { id: 'default-org-id' },
update: {
logoUrl: 'https://images.unsplash.com/photo-1542744173-8e7e53415bb0?q=80&w=200&h=200&auto=format&fit=crop',
},
create: {
id: 'default-org-id',
name: 'Default Organization',
logoUrl: 'https://images.unsplash.com/photo-1542744173-8e7e53415bb0?q=80&w=200&h=200&auto=format&fit=crop',
},
});
const admin = await prisma.user.upsert({
where: { email: 'admin@example.com' },
update: {},
create: {
email: 'admin@example.com',
password: hashedPassword,
role: Role.ADMIN,
organizationId: org.id,
},
});
const building = await prisma.building.create({
data: {
name: 'Headquarters',
organizationId: org.id,
floors: {
create: [
{
number: 1,
spaces: {
create: [
{ name: 'Desk 101', type: SpaceType.DESK, x: 10, y: 10 },
{ name: 'Meeting Room A', type: SpaceType.MEETING_ROOM, x: 50, y: 50 },
{ name: 'Kitchen', type: SpaceType.AMENITY, x: 80, y: 80 },
],
},
},
{
number: 2,
spaces: {
create: [
{ name: 'Desk 201', type: SpaceType.DESK, x: 20, y: 20 },
{ name: 'Boardroom', type: SpaceType.MEETING_ROOM, x: 60, y: 60 },
],
},
},
],
},
},
});
console.log({ org, admin, building });
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@ -1,12 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

View File

@ -1,36 +0,0 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';
import { SpacesModule } from './spaces/spaces.module';
import { AuthModule } from './auth/auth.module';
import { ReservationsModule } from './reservations/reservations.module';
import { EventsModule } from './events/events.module';
import { ReportsModule } from './reports/reports.module';
import { UsersModule } from './users/users.module';
import { OrganizationsModule } from './organizations/organizations.module';
import { AssetsModule } from './assets/assets.module';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
SpacesModule,
AuthModule,
ReservationsModule,
EventsModule,
ReportsModule,
UsersModule,
OrganizationsModule,
AssetsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes('*');
}
}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@ -1,197 +0,0 @@
import {
Controller,
Get,
Post,
Param,
UseInterceptors,
UploadedFile,
Res,
ParseFilePipe,
MaxFileSizeValidator,
FileTypeValidator,
UseGuards,
ForbiddenException,
Req,
NotFoundException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { AssetsService } from './assets.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('assets')
export class AssetsController {
constructor(private readonly assetsService: AssetsService) {}
@Post('organization/:id/logo')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)
@UseInterceptors(FileInterceptor('file'))
async uploadLogo(
@Param('id') id: string,
@Req() req,
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 1024 * 1024 * 10 }), // 10MB
new FileTypeValidator({ fileType: 'image/(jpeg|png|svg+xml|webp)' }),
],
}),
)
file: Express.Multer.File,
) {
if (req.user.organizationId !== id) {
throw new ForbiddenException('You can only upload logo for your own organization');
}
return this.assetsService.uploadOrganizationLogo(id, file);
}
@Post('building/:id/image')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)
@UseInterceptors(FileInterceptor('file'))
async uploadBuildingImage(
@Param('id') id: string,
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 1024 * 1024 * 10 }), // 10MB
new FileTypeValidator({ fileType: 'image/(jpeg|png|webp)' }),
],
}),
)
file: Express.Multer.File,
) {
// In a real app, check if user belongs to the organization that owns this building
return this.assetsService.uploadBuildingImage(id, file);
}
@Post('floor/:id/plan')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)
@UseInterceptors(FileInterceptor('file'))
async uploadFloorPlan(
@Param('id') id: string,
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 1024 * 1024 * 10 }), // 10MB
new FileTypeValidator({ fileType: 'image/(jpeg|png|svg+xml|webp)' }),
],
}),
)
file: Express.Multer.File,
) {
// In a real app, check permissions
return this.assetsService.uploadFloorPlan(id, file);
}
@Post('user/avatar')
@UseGuards(JwtAuthGuard)
@UseInterceptors(FileInterceptor('file'))
async uploadUserAvatar(
@Req() req,
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 1024 * 1024 * 5 }), // 5MB
new FileTypeValidator({ fileType: 'image/(jpeg|png|webp)' }),
],
}),
)
file: Express.Multer.File,
) {
return this.assetsService.uploadUserAvatar(req.user.id, file);
}
@Get(':id')
async getAsset(@Param('id') id: string, @Res() res: Response) {
try {
const asset = await this.assetsService.getAsset(id);
res.set({
'Content-Type': asset.mimeType || 'image/png',
'Content-Length': asset.data.length,
'Cache-Control': 'public, max-age=31536000', // 1 year cache for unique assets
});
res.end(asset.data);
} catch (error) {
if (error instanceof NotFoundException) {
res.status(404).json({
statusCode: 404,
message: 'Asset not found',
});
return;
}
throw error;
}
}
@Get('organization/:id/logo')
async getLogo(@Param('id') id: string, @Res() res: Response) {
try {
const asset = await this.assetsService.getOrganizationLogo(id);
res.set({
'Content-Type': asset.mimeType || 'image/png',
'Content-Length': asset.data.length,
'Cache-Control': 'no-cache', // Might change for the same entity ID
});
res.end(asset.data);
} catch (error) {
if (error instanceof NotFoundException) {
res.status(404).json({
statusCode: 404,
message: 'Logo not found',
});
return;
}
throw error;
}
}
@Get('building/:id/image')
async getBuildingImage(@Param('id') id: string, @Res() res: Response) {
try {
const asset = await this.assetsService.getBuildingImage(id);
res.set({
'Content-Type': asset.mimeType || 'image/png',
'Content-Length': asset.data.length,
'Cache-Control': 'no-cache',
});
res.end(asset.data);
} catch (error) {
if (error instanceof NotFoundException) {
res.status(404).json({
statusCode: 404,
message: 'Building image not found',
});
return;
}
throw error;
}
}
@Get('floor/:id/plan')
async getFloorPlan(@Param('id') id: string, @Res() res: Response) {
try {
const asset = await this.assetsService.getFloorPlan(id);
res.set({
'Content-Type': asset.mimeType || 'image/png',
'Content-Length': asset.data.length,
'Cache-Control': 'no-cache',
});
res.end(asset.data);
} catch (error) {
if (error instanceof NotFoundException) {
res.status(404).json({
statusCode: 404,
message: 'Floor plan not found',
});
return;
}
throw error;
}
}
}

View File

@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { AssetsService } from './assets.service';
import { AssetsController } from './assets.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
providers: [AssetsService],
controllers: [AssetsController],
})
export class AssetsModule {}

View File

@ -1,128 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AssetsService {
constructor(private prisma: PrismaService) {}
async uploadOrganizationLogo(id: string, file: Express.Multer.File) {
const asset = await this.prisma.asset.create({
data: {
data: Buffer.from(file.buffer),
mimeType: file.mimetype,
},
});
return this.prisma.organization.update({
where: { id },
data: {
logoId: asset.id,
logoUrl: `/assets/${asset.id}`,
},
});
}
async deleteOrganizationLogo(id: string) {
return this.prisma.organization.update({
where: { id },
data: {
logoId: null,
logoUrl: null,
},
});
}
async uploadBuildingImage(id: string, file: Express.Multer.File) {
const asset = await this.prisma.asset.create({
data: {
data: Buffer.from(file.buffer),
mimeType: file.mimetype,
},
});
return this.prisma.building.update({
where: { id },
data: {
imageId: asset.id,
imageUrl: `/assets/${asset.id}`,
},
});
}
async uploadFloorPlan(id: string, file: Express.Multer.File) {
const asset = await this.prisma.asset.create({
data: {
data: Buffer.from(file.buffer),
mimeType: file.mimetype,
},
});
return this.prisma.floor.update({
where: { id },
data: {
planImageId: asset.id,
planImageUrl: `/assets/${asset.id}`,
},
});
}
async uploadUserAvatar(id: string, file: Express.Multer.File) {
const asset = await this.prisma.asset.create({
data: {
data: Buffer.from(file.buffer),
mimeType: file.mimetype,
},
});
return this.prisma.user.update({
where: { id },
data: {
avatarId: asset.id,
avatarUrl: `/assets/${asset.id}`,
},
});
}
async getOrganizationLogo(id: string) {
const org = await this.prisma.organization.findUnique({
where: { id },
include: { logo: true },
});
if (!org || !org.logo) {
throw new NotFoundException('Logo not found');
}
return org.logo;
}
async getAsset(id: string) {
const asset = await this.prisma.asset.findUnique({
where: { id },
});
if (!asset) {
throw new NotFoundException('Asset not found');
}
return asset;
}
async getBuildingImage(id: string) {
const building = await this.prisma.building.findUnique({
where: { id },
include: { image: true },
});
if (!building || !building.image) {
throw new NotFoundException('Building image not found');
}
return building.image;
}
async getFloorPlan(id: string) {
const floor = await this.prisma.floor.findUnique({
where: { id },
include: { planImage: true },
});
if (!floor || !floor.planImage) {
throw new NotFoundException('Floor plan not found');
}
return floor.planImage;
}
}

View File

@ -1,19 +0,0 @@
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
async login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
@Post('register')
async register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
}
}

View File

@ -1,25 +0,0 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET') || 'secret',
signOptions: { expiresIn: '1d' },
}),
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}

View File

@ -1,77 +0,0 @@
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
) {}
async login(loginDto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { email: loginDto.email },
});
if (user && (await bcrypt.compare(loginDto.password, user.password))) {
const payload = {
sub: user.id,
email: user.email,
role: user.role,
organizationId: user.organizationId,
};
return {
access_token: this.jwtService.sign(payload),
user: {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl,
avatarId: user.avatarId,
role: user.role,
organizationId: user.organizationId,
},
};
}
throw new UnauthorizedException('Invalid credentials');
}
async register(registerDto: RegisterDto) {
try {
const payload = this.jwtService.verify(registerDto.token);
if (payload.type !== 'INVITE') {
throw new BadRequestException('Invalid invite token');
}
const existing = await this.prisma.user.findUnique({
where: { email: registerDto.email },
});
if (existing) {
throw new BadRequestException('Email already registered');
}
const hashedPassword = await bcrypt.hash(registerDto.password, 10);
const user = await this.prisma.user.create({
data: {
email: registerDto.email,
password: hashedPassword,
role: payload.role,
organizationId: payload.organizationId,
},
});
return {
id: user.id,
email: user.email,
role: user.role,
};
} catch (e) {
if (e instanceof BadRequestException) throw e;
throw new BadRequestException('Invalid or expired invite token');
}
}
}

View File

@ -1,5 +0,0 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

View File

@ -1,10 +0,0 @@
import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsNotEmpty()
@MinLength(6)
password: string;
}

View File

@ -1,16 +0,0 @@
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
export class RegisterDto {
@IsEmail()
@IsNotEmpty()
email: string;
@IsString()
@IsNotEmpty()
@MinLength(6)
password: string;
@IsString()
@IsNotEmpty()
token: string;
}

View File

@ -1,5 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@ -1,28 +0,0 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '@prisma/client';
import { ROLES_KEY } from '../decorators/roles.decorator';
interface RequestWithUser {
user: {
role: Role;
};
}
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
return user && requiredRoles.some((role) => user.role === role);
}
}

View File

@ -1,29 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET') || 'secret',
});
}
validate(payload: {
sub: string;
email: string;
role: string;
organizationId: string;
}) {
return {
id: payload.sub,
email: payload.email,
role: payload.role,
organizationId: payload.organizationId,
};
}
}

View File

@ -1,38 +0,0 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const isHttpException = exception instanceof HttpException;
const status = isHttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
if (!isHttpException) {
console.error(exception);
}
const message = isHttpException
? exception.getResponse()
: 'Internal server error';
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message,
});
}
}

View File

@ -1,23 +0,0 @@
import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
private logger = new Logger('HTTP');
use(request: Request, response: Response, next: NextFunction): void {
const { method, originalUrl } = request;
const userAgent = request.get('user-agent') || '';
response.on('finish', () => {
const { statusCode } = response;
const contentLength = response.get('content-length');
this.logger.log(
`${method} ${originalUrl} ${statusCode} ${contentLength} - ${userAgent}`,
);
});
next();
}
}

View File

@ -1,47 +0,0 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
@WebSocketGateway({
cors: {
origin: '*',
},
})
export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
handleConnection(client: Socket) {
console.log(`Client connected: ${client.id}`);
}
handleDisconnect(client: Socket) {
console.log(`Client disconnected: ${client.id}`);
}
@SubscribeMessage('join_floor')
handleJoinFloor(client: Socket, floorId: string) {
client.join(floorId);
console.log(`Client ${client.id} joined floor: ${floorId}. Rooms:`, Array.from(client.rooms));
}
@SubscribeMessage('leave_floor')
handleLeaveFloor(client: Socket, floorId: string) {
client.leave(floorId);
console.log(`Client ${client.id} left floor: ${floorId}`);
}
emitSpaceStatusChanged(floorId: string, spaceId: string, isOccupied: boolean, reservationData?: any) {
console.log(`Emitting space_status_changed to floor ${floorId}:`, { spaceId, isOccupied });
this.server.to(floorId).emit('space_status_changed', {
spaceId,
isOccupied,
...reservationData,
});
}
}

View File

@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { EventsGateway } from './events.gateway';
@Module({
providers: [EventsGateway],
exports: [EventsGateway],
})
export class EventsModule {}

View File

@ -1,27 +0,0 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
skipMissingProperties: false,
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.enableCors({
origin: ['http://localhost:5173'],
credentials: true,
});
await app.listen(process.env.PORT ?? 3000);
}
bootstrap().catch((err) => console.error(err));

View File

@ -1,16 +0,0 @@
import { IsString, IsOptional, IsUrl } from 'class-validator';
export class UpdateOrganizationDto {
@IsString()
@IsOptional()
name?: string;
@IsOptional()
// @IsUrl()
@IsString()
logoUrl?: string | null;
@IsOptional()
@IsString()
language?: string;
}

View File

@ -1,31 +0,0 @@
import { Controller, Get, Patch, Body, Param, UseGuards, Req, ForbiddenException } from '@nestjs/common';
import { OrganizationsService } from './organizations.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import { UpdateOrganizationDto } from './dto/update-organization.dto';
@Controller('organizations')
@UseGuards(JwtAuthGuard, RolesGuard)
export class OrganizationsController {
constructor(private readonly organizationsService: OrganizationsService) {}
@Get(':id')
async findOne(@Param('id') id: string, @Req() req) {
// Only allow users to see their own organization (or implement more complex logic if needed)
if (req.user.organizationId !== id && req.user.role !== Role.ADMIN) {
throw new ForbiddenException('You can only access your own organization');
}
return this.organizationsService.findOne(id);
}
@Patch(':id')
@Roles(Role.ADMIN)
async update(@Param('id') id: string, @Req() req, @Body() updateOrganizationDto: UpdateOrganizationDto) {
if (req.user.organizationId !== id) {
throw new ForbiddenException('You can only update your own organization');
}
return this.organizationsService.update(id, updateOrganizationDto);
}
}

View File

@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { OrganizationsService } from './organizations.service';
import { OrganizationsController } from './organizations.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [OrganizationsController],
providers: [OrganizationsService],
exports: [OrganizationsService],
})
export class OrganizationsModule {}

View File

@ -1,34 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { UpdateOrganizationDto } from './dto/update-organization.dto';
@Injectable()
export class OrganizationsService {
constructor(private prisma: PrismaService) {}
async findOne(id: string) {
const org = await this.prisma.organization.findUnique({
where: { id },
});
if (!org) {
throw new NotFoundException(`Organization with ID ${id} not found`);
}
return org;
}
async update(id: string, updateOrganizationDto: UpdateOrganizationDto) {
const data: any = { ...updateOrganizationDto };
// If logoUrl is being set to something that is not an internal asset, clear logoId
if (updateOrganizationDto.logoUrl !== undefined) {
if (updateOrganizationDto.logoUrl === null || updateOrganizationDto.logoUrl === '' || !updateOrganizationDto.logoUrl.startsWith('/assets/')) {
data.logoId = null;
}
}
return this.prisma.organization.update({
where: { id },
data,
});
}
}

View File

@ -1,10 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { SeedService } from './seed.service';
@Global()
@Module({
providers: [PrismaService, SeedService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -1,9 +0,0 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}

View File

@ -1,46 +0,0 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import * as bcrypt from 'bcrypt';
import { Role } from '@prisma/client';
@Injectable()
export class SeedService implements OnModuleInit {
private readonly logger = new Logger(SeedService.name);
constructor(private prisma: PrismaService) {}
async onModuleInit() {
await this.seed();
}
async seed() {
const userCount = await this.prisma.user.count();
if (userCount === 0) {
this.logger.log('No users found in database. Seeding default admin account...');
const hashedPassword = await bcrypt.hash('admin123', 10);
const org = await this.prisma.organization.upsert({
where: { id: 'default-org-id' },
update: {},
create: {
id: 'default-org-id',
name: 'Default Organization',
logoUrl: 'https://images.unsplash.com/photo-1542744173-8e7e53415bb0?q=80&w=200&h=200&auto=format&fit=crop',
},
});
await this.prisma.user.create({
data: {
email: 'admin@example.com',
password: hashedPassword,
role: Role.ADMIN,
organizationId: org.id,
},
});
this.logger.log('Default admin account created: admin@example.com / admin123');
}
}
}

View File

@ -1,32 +0,0 @@
import { Controller, Get, UseGuards, Req, Res, Header } from '@nestjs/common';
import { ReportsService } from './reports.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import * as express from 'express';
@Controller('reports')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN)
export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}
@Get('utilization')
getUtilization(@Req() req) {
return this.reportsService.getUtilizationStats(req.user.organizationId);
}
@Get('employees')
getEmployees(@Req() req) {
return this.reportsService.getEmployeeStats(req.user.organizationId);
}
@Get('export')
@Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
@Header('Content-Disposition', 'attachment; filename="reservations-report.xlsx"')
async export(@Req() req, @Res() res: express.Response) {
const buffer = await this.reportsService.generateXlsxReport(req.user.organizationId);
res.send(buffer);
}
}

View File

@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { ReportsService } from './reports.service';
import { ReportsController } from './reports.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
providers: [ReportsService],
controllers: [ReportsController],
})
export class ReportsModule {}

View File

@ -1,153 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import * as XLSX from 'xlsx';
import { SpaceType } from '@prisma/client';
@Injectable()
export class ReportsService {
constructor(private prisma: PrismaService) {}
async getUtilizationStats(organizationId: string) {
const now = new Date();
// Get all spaces in the organization
const spaces = await this.prisma.space.findMany({
where: {
floor: {
building: {
organizationId
}
}
},
include: {
floor: {
include: {
building: true
}
},
reservations: {
where: {
startTime: { lte: now },
endTime: { gte: now }
}
}
}
});
const totalSpaces = spaces.length;
const occupiedSpaces = spaces.filter(s => s.reservations.length > 0).length;
// Stats by building
const buildings = await this.prisma.building.findMany({
where: { organizationId },
include: {
floors: {
include: {
_count: {
select: { spaces: true }
}
}
}
}
});
const buildingStats = await Promise.all(buildings.map(async (b) => {
const buildingSpaces = spaces.filter(s => s.floor.buildingId === b.id);
const buildingOccupied = buildingSpaces.filter(s => s.reservations.length > 0).length;
return {
id: b.id,
name: b.name,
totalSpaces: buildingSpaces.length,
occupiedSpaces: buildingOccupied,
utilization: buildingSpaces.length > 0 ? (buildingOccupied / buildingSpaces.length) * 100 : 0
};
}));
// Stats by space type
const typeStats = Object.values(SpaceType).map(type => {
const typeSpaces = spaces.filter(s => s.type === type);
const typeOccupied = typeSpaces.filter(s => s.reservations.length > 0).length;
return {
type,
total: typeSpaces.length,
occupied: typeOccupied,
utilization: typeSpaces.length > 0 ? (typeOccupied / typeSpaces.length) * 100 : 0
};
});
return {
summary: {
totalSpaces,
occupiedSpaces,
overallUtilization: totalSpaces > 0 ? (occupiedSpaces / totalSpaces) * 100 : 0
},
buildingStats,
typeStats
};
}
async getEmployeeStats(organizationId: string) {
const users = await this.prisma.user.findMany({
where: { organizationId },
include: {
_count: {
select: { reservations: true }
}
}
});
const roleDistribution = users.reduce((acc, user) => {
acc[user.role] = (acc[user.role] || 0) + 1;
return acc;
}, {} as Record<string, number>);
return {
totalEmployees: users.length,
roleDistribution,
topBookers: users
.sort((a, b) => b._count.reservations - a._count.reservations)
.slice(0, 5)
.map(u => ({ email: u.email, reservationsCount: u._count.reservations }))
};
}
async generateXlsxReport(organizationId: string) {
const reservations = await this.prisma.reservation.findMany({
where: {
user: { organizationId }
},
include: {
user: { select: { email: true, role: true } },
space: {
include: {
floor: {
include: { building: true }
}
}
}
},
orderBy: { startTime: 'desc' }
});
const data = reservations.map(r => ({
'Reservation ID': r.id,
'User Email': r.user.email,
'User Role': r.user.role,
'Building': r.space.floor.building.name,
'Floor': r.space.floor.number,
'Space Name': r.space.name,
'Space Type': r.space.type,
'Start Time': r.startTime,
'End Time': r.endTime,
'Created At': r.createdAt
}));
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet(data);
XLSX.utils.book_append_sheet(wb, ws, 'Reservations');
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
return buf;
}
}

View File

@ -1,23 +0,0 @@
import { IsString, IsDateString, IsNotEmpty, IsOptional } from 'class-validator';
export class CreateReservationDto {
@IsDateString()
@IsNotEmpty()
startTime: string;
@IsDateString()
@IsNotEmpty()
endTime: string;
@IsString()
@IsNotEmpty()
spaceId: string;
@IsString()
@IsOptional()
userId?: string;
@IsString()
@IsOptional()
note?: string;
}

View File

@ -1,23 +0,0 @@
import { IsString, IsDateString, IsOptional } from 'class-validator';
export class UpdateReservationDto {
@IsDateString()
@IsOptional()
startTime?: string;
@IsDateString()
@IsOptional()
endTime?: string;
@IsString()
@IsOptional()
spaceId?: string;
@IsString()
@IsOptional()
userId?: string;
@IsString()
@IsOptional()
note?: string;
}

View File

@ -1,67 +0,0 @@
import {
Controller,
Post,
Delete,
Get,
Body,
Param,
UseGuards,
Req,
Patch,
ForbiddenException,
} from '@nestjs/common';
import { ReservationsService } from './reservations.service';
import { CreateReservationDto } from './dto/create-reservation.dto';
import { UpdateReservationDto } from './dto/update-reservation.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { Role } from '@prisma/client';
@Controller('reservations')
@UseGuards(JwtAuthGuard)
export class ReservationsController {
constructor(private readonly reservationsService: ReservationsService) {}
@Post()
create(@Req() req, @Body() createReservationDto: CreateReservationDto) {
let targetUserId = req.user.id;
if (createReservationDto.userId && createReservationDto.userId !== req.user.id) {
if (req.user.role !== Role.ADMIN && req.user.role !== Role.SUPERVISOR) {
throw new ForbiddenException('Only admins and supervisors can book for other people');
}
targetUserId = createReservationDto.userId;
}
return this.reservationsService.create(targetUserId, createReservationDto);
}
@Get('my')
findAllMy(@Req() req) {
return this.reservationsService.findAllByUserId(req.user.id);
}
@Get('space/:spaceId')
findAllBySpaceId(@Param('spaceId') spaceId: string, @Req() req) {
// Optionally we could filter by date if passed as query param
return this.reservationsService.findAllBySpaceId(spaceId);
}
@Patch(':id')
update(@Param('id') id: string, @Req() req, @Body() updateReservationDto: UpdateReservationDto) {
let targetUserId = req.user.id;
if (updateReservationDto.userId && updateReservationDto.userId !== req.user.id) {
if (req.user.role !== Role.ADMIN && req.user.role !== Role.SUPERVISOR) {
throw new ForbiddenException('Only admins and supervisors can change the owner of a reservation');
}
targetUserId = updateReservationDto.userId;
}
return this.reservationsService.update(targetUserId, id, updateReservationDto);
}
@Delete(':id')
cancel(@Param('id') id: string, @Req() req) {
return this.reservationsService.cancel(req.user.id, id, req.user.role);
}
}

View File

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { ReservationsService } from './reservations.service';
import { ReservationsController } from './reservations.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { EventsModule } from '../events/events.module';
@Module({
imports: [PrismaModule, EventsModule],
controllers: [ReservationsController],
providers: [ReservationsService],
exports: [ReservationsService],
})
export class ReservationsModule {}

View File

@ -1,335 +0,0 @@
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EventsGateway } from '../events/events.gateway';
import { CreateReservationDto } from './dto/create-reservation.dto';
import { UpdateReservationDto } from './dto/update-reservation.dto';
import { SpaceType } from '@prisma/client';
@Injectable()
export class ReservationsService {
constructor(
private prisma: PrismaService,
private eventsGateway: EventsGateway,
) {}
async create(userId: string, createReservationDto: CreateReservationDto) {
const { spaceId, startTime, endTime, userId: requestedUserId } = createReservationDto;
const targetUserId = requestedUserId || userId;
const space = await this.prisma.space.findUnique({
where: { id: spaceId },
});
if (!space) {
throw new NotFoundException(`Space with ID ${spaceId} not found`);
}
const start = new Date(startTime);
const end = new Date(endTime);
// Validation
if (start >= end) {
throw new BadRequestException('Start time must be before end time');
}
const now = new Date();
if (space.type === SpaceType.DESK) {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (start < today) {
throw new BadRequestException('Cannot book desks in the past');
}
} else if (start < now) {
throw new BadRequestException('Cannot book in the past');
}
// Rules
if (space.type === SpaceType.DESK) {
// Desks: Full day (setting to midnight of start and end of that day)
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
} else if (space.type === SpaceType.MEETING_ROOM) {
// Meeting rooms: 15-min increments, between 07:00 and 20:00
if (start.getHours() < 7 || (end.getHours() > 20 || (end.getHours() === 20 && end.getMinutes() > 0))) {
throw new BadRequestException('Meeting rooms can only be booked between 07:00 and 20:00');
}
if (start.getMinutes() % 15 !== 0 || end.getMinutes() % 15 !== 0) {
throw new BadRequestException('Meeting rooms can only be booked in 15-minute increments');
}
} else if (space.type === SpaceType.AMENITY) {
throw new BadRequestException('Amenities cannot be booked');
}
// Overlap check
const existing = await this.prisma.reservation.findFirst({
where: {
spaceId,
OR: [
{ startTime: { lt: end }, endTime: { gt: start } },
],
},
});
if (existing) {
throw new BadRequestException('Space is already booked for the selected time');
}
const reservation = await this.prisma.reservation.create({
data: {
startTime: start,
endTime: end,
userId: targetUserId,
spaceId,
note: createReservationDto.note,
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
avatarUrl: true,
avatarId: true,
role: true,
}
}
}
});
// Notify clients on the floor
this.eventsGateway.emitSpaceStatusChanged(space.floorId, spaceId, true, {
reservation: {
id: reservation.id,
startTime: reservation.startTime,
endTime: reservation.endTime,
user: reservation.user
}
});
console.log('Emitted space_status_changed for new reservation', space.floorId, spaceId);
return reservation;
}
async cancel(userId: string, reservationId: string, userRole?: string) {
const reservation = await this.prisma.reservation.findUnique({
where: { id: reservationId },
include: {
space: true,
user: {
select: {
role: true
}
}
},
});
if (!reservation) {
throw new NotFoundException(`Reservation with ID ${reservationId} not found`);
}
if (reservation.userId !== userId && userRole !== Role.ADMIN && userRole !== Role.SUPERVISOR) {
throw new ForbiddenException('You can only cancel your own reservations unless you are an administrator or supervisor');
}
await this.prisma.reservation.delete({
where: { id: reservationId },
});
// Notify clients on the floor
this.eventsGateway.emitSpaceStatusChanged(
reservation.space.floorId,
reservation.spaceId,
false,
{
reservation: {
id: reservation.id,
startTime: reservation.startTime,
endTime: reservation.endTime,
}
}
);
console.log('Emitted space_status_changed for cancelled reservation', reservation.space.floorId, reservation.spaceId);
return { message: 'Reservation cancelled' };
}
async findAllByUserId(userId: string) {
return this.prisma.reservation.findMany({
where: { userId },
include: {
space: {
include: {
floor: {
include: {
building: true,
},
},
},
},
},
orderBy: {
startTime: 'desc',
},
});
}
async update(userId: string, id: string, updateReservationDto: UpdateReservationDto) {
const reservation = await this.prisma.reservation.findUnique({
where: { id },
});
if (!reservation) {
throw new NotFoundException(`Reservation with ID ${id} not found`);
}
const isDifferentUser = reservation.userId !== userId;
if (isDifferentUser) {
// Check if the current user is an admin or supervisor
const requester = await this.prisma.user.findUnique({ where: { id: userId } });
if (!requester || (requester.role !== Role.ADMIN && requester.role !== Role.SUPERVISOR)) {
throw new ForbiddenException('You can only update your own reservations');
}
}
const spaceId = updateReservationDto.spaceId || reservation.spaceId;
const startTime = updateReservationDto.startTime || reservation.startTime;
const endTime = updateReservationDto.endTime || reservation.endTime;
const targetUserId = updateReservationDto.userId === null ? null : (updateReservationDto.userId || reservation.userId);
const note = updateReservationDto.note === undefined ? reservation.note : updateReservationDto.note;
const space = await this.prisma.space.findUnique({
where: { id: spaceId },
});
if (!space) {
throw new NotFoundException(`Space with ID ${spaceId} not found`);
}
const start = new Date(startTime);
const end = new Date(endTime);
// Validation
if (start >= end) {
throw new BadRequestException('Start time must be before end time');
}
const now = new Date();
if (space.type === SpaceType.DESK) {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (start < today) {
throw new BadRequestException('Cannot update desk reservation to the past');
}
} else if (start < now) {
throw new BadRequestException('Cannot update reservation to the past');
}
// Rules
if (space.type === SpaceType.DESK) {
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
} else if (space.type === SpaceType.MEETING_ROOM) {
if (start.getHours() < 7 || (end.getHours() > 20 || (end.getHours() === 20 && end.getMinutes() > 0))) {
throw new BadRequestException('Meeting rooms can only be booked between 07:00 and 20:00');
}
if (start.getMinutes() % 15 !== 0 || end.getMinutes() % 15 !== 0) {
throw new BadRequestException('Meeting rooms can only be booked in 15-minute increments');
}
} else if (space.type === SpaceType.AMENITY) {
throw new BadRequestException('Amenities cannot be booked');
}
// Overlap check
const existing = await this.prisma.reservation.findFirst({
where: {
spaceId,
id: { not: id },
OR: [
{ startTime: { lt: end }, endTime: { gt: start } },
],
},
});
if (existing) {
throw new BadRequestException('Space is already booked for the selected time');
}
const updated = await this.prisma.reservation.update({
where: { id },
data: {
startTime: start,
endTime: end,
spaceId,
userId: targetUserId,
note,
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
avatarUrl: true,
avatarId: true,
role: true,
}
},
space: true
}
});
// Notify clients on the floor if space or time changed
if (reservation.spaceId !== updated.spaceId) {
// Notify old floor
const oldSpace = await this.prisma.space.findUnique({ where: { id: reservation.spaceId } });
if (oldSpace) {
this.eventsGateway.emitSpaceStatusChanged(oldSpace.floorId, reservation.spaceId, false);
}
// Notify new floor
this.eventsGateway.emitSpaceStatusChanged(space.floorId, spaceId, true, {
reservation: {
id: updated.id,
startTime: updated.startTime,
endTime: updated.endTime,
user: updated.user
}
});
} else {
// Just notify current floor
this.eventsGateway.emitSpaceStatusChanged(space.floorId, spaceId, true, {
reservation: {
id: updated.id,
startTime: updated.startTime,
endTime: updated.endTime,
user: updated.user
}
});
}
return updated;
}
async findAllBySpaceId(spaceId: string) {
return this.prisma.reservation.findMany({
where: { spaceId },
include: {
user: {
select: {
id: true,
email: true,
name: true,
avatarUrl: true,
avatarId: true,
role: true,
}
}
},
orderBy: {
startTime: 'asc',
},
});
}
}

View File

@ -1,49 +0,0 @@
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,39 +0,0 @@
import { IsString, IsNumber, IsOptional, IsEnum, IsUUID } from 'class-validator';
import { SpaceType } from '@prisma/client';
export class UpdateSpaceDto {
@IsString()
id: string;
@IsNumber()
@IsOptional()
x?: number;
@IsNumber()
@IsOptional()
y?: number;
@IsNumber()
@IsOptional()
width?: number;
@IsNumber()
@IsOptional()
height?: number;
@IsNumber()
@IsOptional()
rotation?: number;
@IsString()
@IsOptional()
name?: string;
@IsEnum(SpaceType)
@IsOptional()
type?: SpaceType;
@IsUUID()
@IsOptional()
floorId?: string;
}

View File

@ -1,77 +0,0 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request, Patch, Query, ParseArrayPipe, Delete } from '@nestjs/common';
import { SpacesService } from './spaces.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import { UpdateSpaceDto } from './dto/update-space.dto';
import { CreateBuildingDto, UpdateBuildingDto, CreateFloorDto, UpdateFloorDto } from './dto/building-floor.dto';
@Controller('spaces')
@UseGuards(JwtAuthGuard, RolesGuard)
export class SpacesController {
constructor(private readonly spacesService: SpacesService) {}
@Get('floor/:id')
async getFloorLayout(@Param('id') id: string, @Query('date') date?: string) {
return this.spacesService.getFloorLayout(id, date ? new Date(date) : undefined);
}
@Get('building/:id')
async getBuildingFloors(@Param('id') id: string) {
return this.spacesService.getBuildingFloors(id);
}
@Get('org-hierarchy')
async getOrgHierarchy(@Request() req: { user: { organizationId: string } }) {
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')
@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(new ParseArrayPipe({ items: UpdateSpaceDto })) spaces: UpdateSpaceDto[]) {
return this.spacesService.batchUpdateSpaces(spaces);
}
}

View File

@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { SpacesController } from './spaces.controller';
import { SpacesService } from './spaces.service';
@Module({
controllers: [SpacesController],
providers: [SpacesService],
})
export class SpacesModule {}

View File

@ -1,188 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { UpdateSpaceDto } from './dto/update-space.dto';
@Injectable()
export class SpacesService {
private readonly logger = new Logger(SpacesService.name);
constructor(private prisma: PrismaService) {}
async getFloorLayout(floorId: string, date: Date = new Date()) {
return this.prisma.floor.findUnique({
where: { id: floorId },
include: {
spaces: {
include: {
reservations: {
where: {
endTime: {
gte: date,
},
startTime: {
lte: date,
},
},
include: {
user: {
select: {
id: true,
email: true,
role: true,
},
},
},
},
},
},
building: {
include: {
organization: true,
},
},
},
});
}
async getBuildingFloors(buildingId: string) {
return this.prisma.building.findUnique({
where: { id: buildingId },
include: {
floors: {
orderBy: { number: 'asc' }
},
},
});
}
async getBuildingsAndFloors(organizationId: string) {
return this.prisma.building.findMany({
where: { organizationId },
include: {
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, dto: any) {
const data: any = { ...dto };
// If imageUrl is being set to something that is not an internal asset, clear imageId
if (dto.imageUrl !== undefined) {
if (dto.imageUrl === null || dto.imageUrl === '' || !dto.imageUrl.startsWith('/assets/')) {
data.imageId = null;
}
}
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, dto: any) {
const data: any = { ...dto };
// If planImageUrl is being set to something that is not an internal asset, clear planImageId
if (dto.planImageUrl !== undefined) {
if (dto.planImageUrl === null || dto.planImageUrl === '' || !dto.planImageUrl.startsWith('/assets/')) {
data.planImageId = null;
}
}
return this.prisma.floor.update({
where: { id },
data,
});
}
async deleteFloor(id: string) {
return this.prisma.floor.delete({
where: { id },
});
}
async updateFloorPlan(floorId: string, planImageUrl: string) {
const data: any = { planImageUrl };
// If planImageUrl is being set to something that is not an internal asset, clear planImageId
if (planImageUrl === null || planImageUrl === '' || !planImageUrl.startsWith('/assets/')) {
data.planImageId = null;
}
return this.prisma.floor.update({
where: { id: floorId },
data,
});
}
async batchUpdateSpaces(spaces: UpdateSpaceDto[]) {
const updates = spaces.map((space) => {
const { id, ...data } = space;
if (id.startsWith('temp-')) {
if (!data.type) {
throw new Error(`Space type is required for new spaces`);
}
if (!data.floorId) {
throw new Error(`FloorId is required for new spaces`);
}
// Create new space if it's a new one from the toolbox
return this.prisma.space.create({
data: {
name: data.name || `${data.type} ${Math.floor(Math.random() * 1000)}`,
type: data.type,
floorId: data.floorId,
x: data.x,
y: data.y,
width: data.width,
height: data.height,
rotation: data.rotation,
},
});
}
return this.prisma.space.update({
where: { id },
data: {
x: data.x,
y: data.y,
width: data.width,
height: data.height,
rotation: data.rotation,
name: data.name,
},
});
});
return Promise.all(updates);
}
}

View File

@ -1,25 +0,0 @@
import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from 'class-validator';
import { Role } from '@prisma/client';
export class UpdateUserDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
avatarUrl?: string;
@IsEmail()
@IsOptional()
email?: string;
@IsEnum(Role)
@IsOptional()
role?: Role;
@IsString()
@MinLength(6)
@IsOptional()
password?: string;
}

View File

@ -1,47 +0,0 @@
import { Controller, Get, Post, Patch, Body, Param, UseGuards, Req } from '@nestjs/common';
import { UsersService } from './users.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { Role } from '@prisma/client';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get('profile')
getProfile(@Req() req) {
return this.usersService.findOne(req.user.id, req.user.organizationId);
}
@Patch('profile')
updateProfile(@Req() req, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.updateProfile(req.user.id, req.user.organizationId, updateUserDto);
}
@Get()
@Roles(Role.ADMIN)
findAll(@Req() req) {
return this.usersService.findAll(req.user.organizationId);
}
@Get(':id')
@Roles(Role.ADMIN)
findOne(@Param('id') id: string, @Req() req) {
return this.usersService.findOne(id, req.user.organizationId);
}
@Patch(':id')
@Roles(Role.ADMIN)
update(@Param('id') id: string, @Req() req, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(id, req.user.organizationId, updateUserDto);
}
@Post('invite')
@Roles(Role.ADMIN)
createInvite(@Req() req, @Body('role') role?: string) {
return this.usersService.createInvite(req.user.organizationId, role);
}
}

View File

@ -1,22 +0,0 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET') || 'secret',
signOptions: { expiresIn: '7d' },
}),
}),
],
providers: [UsersService],
controllers: [UsersController],
exports: [UsersService],
})
export class UsersModule {}

View File

@ -1,90 +0,0 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { UpdateUserDto } from './dto/update-user.dto';
import * as bcrypt from 'bcrypt';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class UsersService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
) {}
async findAll(organizationId: string) {
return this.prisma.user.findMany({
where: { organizationId },
select: {
id: true,
email: true,
name: true,
avatarUrl: true,
avatarId: true,
role: true,
organizationId: true,
createdAt: true,
updatedAt: true,
},
orderBy: { email: 'asc' },
});
}
async findOne(id: string, organizationId: string) {
const user = await this.prisma.user.findFirst({
where: { id, organizationId },
select: {
id: true,
email: true,
name: true,
avatarUrl: true,
avatarId: true,
role: true,
organizationId: true,
createdAt: true,
updatedAt: true,
},
});
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
async update(id: string, organizationId: string, updateUserDto: UpdateUserDto) {
const user = await this.findOne(id, organizationId);
const data: any = { ...updateUserDto };
if (data.password) {
data.password = await bcrypt.hash(data.password, 10);
}
return this.prisma.user.update({
where: { id: user.id },
data,
select: {
id: true,
email: true,
name: true,
avatarUrl: true,
avatarId: true,
role: true,
organizationId: true,
updatedAt: true,
},
});
}
async updateProfile(id: string, organizationId: string, updateUserDto: UpdateUserDto) {
// Basic user info update - can't change role via profile update
const { role, ...updateData } = updateUserDto;
return this.update(id, organizationId, updateData);
}
async createInvite(organizationId: string, role: string = 'EMPLOYEE') {
const payload = { organizationId, role, type: 'INVITE' };
const token = this.jwtService.sign(payload, { expiresIn: '7d' });
return { token };
}
}

View File

@ -1,25 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View File

@ -1,9 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@ -1,25 +0,0 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}

View File

@ -1 +0,0 @@
VITE_API_BASE_URL="http://localhost:3000"

View File

@ -1,45 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy root configs
COPY package*.json ./
COPY tsconfig.json ./
COPY tsconfig.base.json ./
# Copy packages
COPY apps/web/package*.json ./apps/web/
COPY shared/package*.json ./shared/
# Install dependencies
RUN npm ci
# Copy source code
COPY apps/web ./apps/web
COPY shared ./shared
# Build the app
WORKDIR /app/apps/web
# We need VITE_API_BASE_URL at build time for SvelteKit static replacements if any,
# but usually it's better to use dynamic env vars if possible.
# For now, let's assume it can be provided during build or we use the default.
RUN npm run build
# ---
FROM node:20-alpine AS runner
WORKDIR /app
# Copy build artifacts and necessary files
COPY --from=builder /app/apps/web/build ./apps/web/build
COPY --from=builder /app/apps/web/package.json ./apps/web/package.json
# Node adapter needs dependencies to run
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 5173
ENV PORT=5173
ENV NODE_ENV=production
WORKDIR /app/apps/web
CMD ["node", "build"]

File diff suppressed because it is too large Load Diff

View File

@ -1,37 +0,0 @@
{
"name": "web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"autoprefixer": "^10.4.0",
"lucide-svelte": "^0.300.0",
"postcss": "^8.4.0",
"sade": "^1.8.1",
"svelte": "^5.0.0",
"svelte-check": "^3.8.6",
"tailwindcss": "^3.4.0",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^5.0.0"
},
"type": "module",
"dependencies": {
"@types/crypto-js": "^4.2.2",
"crypto-js": "^4.2.0",
"date-fns": "^4.1.0",
"import-meta-resolve": "^4.2.0",
"interactjs": "^1.10.27",
"socket.io-client": "^4.8.3",
"svelte-i18n": "^4.0.1"
}
}

View File

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -1,42 +0,0 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
export async function apiFetch<T>(
path: string,
options: RequestInit & { fetch?: typeof fetch } = {},
): Promise<T> {
const fetchFn = options.fetch ?? fetch;
const headers: Record<string, string> = {
...(options.headers as Record<string, string> ?? {}),
};
// Only set Content-Type to application/json if it's not already set and it's not a multipart request
if (!headers['Content-Type'] && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
// 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}`, {
...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}`);
}
return res.json() as Promise<T>;
}

View File

@ -1,48 +0,0 @@
<script lang="ts">
import { resolveAssetUrl } from '$lib/utils/url';
import { getGravatarUrl, getInitials } from '$lib/utils/avatar';
export let user: {
name?: string | null;
email: string;
avatarUrl?: string | null;
};
export let size: 'sm' | 'md' | 'lg' = 'md';
export let className: string = '';
const sizeMap = {
sm: 'w-6 h-6 text-[8px]',
md: 'w-10 h-10 text-xs',
lg: 'w-20 h-20 text-xl'
};
let imgError = false;
let gravatarUrl = getGravatarUrl(user.email, size === 'lg' ? 200 : 100);
$: initials = getInitials(user.name || undefined, user.email);
</script>
<div class="relative inline-flex items-center justify-center overflow-hidden rounded-full bg-gray-100 border border-gray-200 shadow-sm {sizeMap[size]} {className}">
{#if user.avatarUrl && !imgError}
<img
src={resolveAssetUrl(user.avatarUrl)}
alt={user.name || user.email}
class="w-full h-full object-cover relative z-10"
on:error={() => imgError = true}
/>
{/if}
{#if !user.avatarUrl || imgError}
<img
src={gravatarUrl}
alt={user.name || user.email}
class="w-full h-full object-cover relative z-10"
/>
{/if}
<div class="absolute inset-0 flex items-center justify-center bg-gray-100">
<span class="font-bold text-gray-600 uppercase">
{initials}
</span>
</div>
</div>

View File

@ -1,526 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { user } from '$lib/stores/auth';
import { apiFetch } from '$lib/api/client';
import { X, Calendar, Clock, Loader2, Trash2, AlertTriangle, User as UserIcon, FileText, Search, ChevronDown } from 'lucide-svelte';
import { t, locale } from 'svelte-i18n';
import { format } from 'date-fns';
import { es, enUS } from 'date-fns/locale';
export let space: any;
export let status: any;
export let selectedDate: Date = new Date();
const dispatch = createEventDispatcher();
let bookingDate = selectedDate.toISOString().split('T')[0];
let reservations: any[] = [];
let loadingReservations = false;
$: dateLocale = $locale === 'es-ES' ? es : enUS;
$: dateFormat = $locale === 'es-ES' ? 'dd/MM/yyyy' : 'PPP';
$: formattedDate = format(selectedDate, dateFormat, { locale: dateLocale });
$: {
if (selectedDate) {
bookingDate = selectedDate.toISOString().split('T')[0];
}
}
$: if (space && space.type === 'MEETING_ROOM') {
fetchReservations();
}
async function fetchReservations() {
loadingReservations = true;
try {
reservations = await apiFetch(`/reservations/space/${space.id}`);
} catch (e) {
console.error("Failed to fetch reservations", e);
} finally {
loadingReservations = false;
}
}
let startTime = "07:00";
let endTime = "08:00";
let loading = false;
let deleting = false;
let confirmDelete = false;
let error = "";
let users: any[] = [];
let loadingUsers = false;
let bookFor = 'myself'; // 'myself', 'other', 'none'
let targetUserId = '';
let note = '';
let searchQuery = '';
let showUserDropdown = false;
$: filteredUsers = users.filter(u =>
(u.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.email?.toLowerCase().includes(searchQuery.toLowerCase()))
);
function selectUser(selectedUser: any) {
targetUserId = selectedUser.id;
searchQuery = selectedUser.name || selectedUser.email;
showUserDropdown = false;
}
$: isAdminOrSupervisor = $user?.role === 'ADMIN' || $user?.role === 'SUPERVISOR';
$: isOwner = status.reservation?.userId === $user?.id || status.reservation?.user?.id === $user?.id;
$: isAdmin = $user?.role === 'ADMIN';
async function fetchUsers() {
if (!isAdminOrSupervisor) return;
loadingUsers = true;
try {
users = await apiFetch('/users');
} catch (e) {
console.error("Failed to fetch users", e);
} finally {
loadingUsers = false;
}
}
$: if (isAdminOrSupervisor && space) {
fetchUsers();
}
const timeOptions = [];
for (let h = 7; h <= 20; h++) {
for (let m = 0; m < 60; m += 15) {
if (h === 20 && m > 0) break;
const hh = h.toString().padStart(2, '0');
const mm = m.toString().padStart(2, '0');
timeOptions.push(`${hh}:${mm}`);
}
}
async function handleBook() {
loading = true;
error = "";
try {
let start, end;
if (space.type === 'DESK') {
start = new Date(bookingDate);
start.setHours(0, 0, 0, 0);
end = new Date(bookingDate);
end.setHours(23, 59, 59, 999);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (start < today) {
error = $t('booking.errorPast');
loading = false;
return;
}
} else {
const [sh, sm] = startTime.split(':').map(Number);
const [eh, em] = endTime.split(':').map(Number);
start = new Date(bookingDate);
start.setHours(sh, sm, 0, 0);
end = new Date(bookingDate);
end.setHours(eh, em, 0, 0);
if (start < new Date()) {
error = $t('booking.errorPast');
loading = false;
return;
}
if (end <= start) {
error = $t('booking.errorTime');
loading = false;
return;
}
}
await apiFetch('/reservations', {
method: 'POST',
body: JSON.stringify({
spaceId: space.id,
startTime: start.toISOString(),
endTime: end.toISOString(),
userId: bookFor === 'other' ? targetUserId : (bookFor === 'none' ? null : $user?.id),
note: note || undefined
})
});
console.log('Booking created successfully');
dispatch('close');
} catch (e: any) {
error = e.message || $t('booking.errorCreating');
} finally {
loading = false;
}
}
async function handleDelete(reservationId: string) {
if (isAdmin && !confirmDelete) {
confirmDelete = true;
return;
}
deleting = true;
error = "";
try {
await apiFetch(`/reservations/${reservationId}`, {
method: 'DELETE'
});
dispatch('close');
} catch (e: any) {
error = e.message || $t('booking.errorDeleting');
confirmDelete = false;
} finally {
deleting = false;
}
}
function formatTime(dateStr: string) {
return new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
}
$: dayReservations = reservations.filter(r => {
const rDate = new Date(r.startTime).toISOString().split('T')[0];
return rDate === bookingDate;
});
$: isSlotBooked = (time: string) => {
const [h, m] = time.split(':').map(Number);
const slotStart = new Date(bookingDate);
slotStart.setHours(h, m, 0, 0);
if (slotStart < new Date()) {
return { isPast: true };
}
return dayReservations.find(r => {
const rStart = new Date(r.startTime);
const rEnd = new Date(r.endTime);
return slotStart >= rStart && slotStart < rEnd;
});
};
function selectSlot(time: string) {
if (isSlotBooked(time)) return;
startTime = time;
// Default end time to 15 mins later, capping at 20:00
const [h, m] = time.split(':').map(Number);
const end = new Date();
end.setHours(h, m + 15);
let eh = end.getHours();
let em = end.getMinutes();
if (eh > 20 || (eh === 20 && em > 0)) {
eh = 20;
em = 0;
}
const ehStr = eh.toString().padStart(2, '0');
const emStr = em.toString().padStart(2, '0');
endTime = `${ehStr}:${emStr}`;
}
</script>
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
<div class="p-6 border-b flex justify-between items-center bg-gray-50">
<div>
<h3 class="text-xl font-bold text-gray-900">{space.name}</h3>
<p class="text-sm text-gray-500 uppercase tracking-wider font-semibold">{$t(`booking.spaceType.${space.type}`)}</p>
</div>
<button on:click={() => dispatch('close')} class="p-2 hover:bg-gray-200 rounded-full transition-colors text-gray-400">
<X size={24} />
</button>
</div>
<div class="p-6">
{#if space.type === 'DESK' && status.isOccupied}
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-4 mb-6">
<p class="text-yellow-800 font-bold mb-1">{$t('booking.currentlyBooked')}</p>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
{#if status.reservation?.user}
<div class="w-10 h-10 rounded-full bg-yellow-200 flex items-center justify-center text-yellow-700 font-bold">
{status.reservation.user.email?.charAt(0).toUpperCase()}
</div>
<div>
<p class="text-sm font-medium text-yellow-900">{status.reservation.user.email}</p>
{#if status.reservation.note}
<p class="text-xs text-yellow-800 italic">"{status.reservation.note}"</p>
{/if}
<p class="text-xs text-yellow-700">
{formatTime(status.reservation.startTime)} - {formatTime(status.reservation.endTime)}
</p>
</div>
{:else}
<div class="w-10 h-10 rounded-full bg-yellow-200 flex items-center justify-center text-yellow-700 font-bold">
<FileText size={20} />
</div>
<div>
<p class="text-sm font-medium text-yellow-900">
{status.reservation.note || $t('booking.nonEmployee')}
</p>
<p class="text-xs text-yellow-700">
{formatTime(status.reservation.startTime)} - {formatTime(status.reservation.endTime)}
</p>
</div>
{/if}
</div>
{#if isOwner || isAdmin}
<div class="flex flex-col gap-2">
<button
on:click={() => handleDelete(status.reservation.id)}
disabled={deleting}
class="flex items-center gap-2 px-4 py-2 bg-red-50 text-red-600 hover:bg-red-100 rounded-xl transition-colors font-bold text-sm border border-red-100 shadow-sm"
>
<Trash2 size={18} />
{$t('booking.delete')}
</button>
</div>
{/if}
</div>
{#if isAdmin && confirmDelete}
<div class="mt-4 p-3 bg-red-100 border border-red-200 rounded-lg">
<div class="flex items-center gap-2 text-red-800 text-sm font-bold mb-2">
<AlertTriangle size={16} />
{$t('booking.adminWarning')}
</div>
<p class="text-xs text-red-700 mb-3">{$t('booking.adminDeleteInfo')}</p>
<div class="flex gap-2">
<button
on:click={() => handleDelete(status.reservation.id)}
class="flex-1 py-2 bg-red-600 text-white text-xs font-bold rounded-md hover:bg-red-700"
>
{$t('booking.confirmDelete')}
</button>
<button
on:click={() => confirmDelete = false}
class="flex-1 py-2 bg-white text-gray-700 text-xs font-bold border border-gray-300 rounded-md hover:bg-gray-50"
>
{$t('common.cancel')}
</button>
</div>
</div>
{/if}
</div>
<p class="text-center text-gray-500 text-sm">{$t('booking.unavailable')}</p>
{:else}
{#if error}
<div class="bg-red-50 text-red-600 p-3 rounded-lg mb-4 text-sm font-medium border border-red-100">
{error}
</div>
{/if}
<div class="space-y-4">
{#if isAdminOrSupervisor}
<div class="bg-gray-50 p-4 rounded-xl border border-gray-100 space-y-3">
<label class="block text-sm font-bold text-gray-700 flex items-center gap-2">
<UserIcon size={16} />
{$t('booking.bookFor')}
</label>
<div class="flex flex-wrap gap-2">
<button
type="button"
on:click={() => bookFor = 'myself'}
class="px-3 py-1.5 text-xs font-bold rounded-full transition-colors border
{bookFor === 'myself' ? 'bg-indigo-600 text-white border-indigo-700' : 'bg-white text-gray-600 border-gray-200 hover:border-indigo-400'}"
>
{$t('booking.myself')}
</button>
<button
type="button"
on:click={() => bookFor = 'other'}
class="px-3 py-1.5 text-xs font-bold rounded-full transition-colors border
{bookFor === 'other' ? 'bg-indigo-600 text-white border-indigo-700' : 'bg-white text-gray-600 border-gray-200 hover:border-indigo-400'}"
>
{$t('booking.someoneElse')}
</button>
<button
type="button"
on:click={() => bookFor = 'none'}
class="px-3 py-1.5 text-xs font-bold rounded-full transition-colors border
{bookFor === 'none' ? 'bg-indigo-600 text-white border-indigo-700' : 'bg-white text-gray-600 border-gray-200 hover:border-indigo-400'}"
>
{$t('booking.nonEmployee')}
</button>
</div>
{#if bookFor === 'other'}
<div class="mt-2 relative">
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400">
<Search size={16} />
</div>
<input
type="text"
bind:value={searchQuery}
on:focus={() => showUserDropdown = true}
placeholder={$t('booking.searchUserPlaceholder')}
class="w-full pl-10 pr-10 py-2.5 bg-white border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 outline-none"
/>
<div class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400">
<ChevronDown size={16} />
</div>
</div>
{#if showUserDropdown && (searchQuery || users.length > 0)}
<div class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-xl max-h-48 overflow-y-auto">
{#if filteredUsers.length === 0}
<div class="p-3 text-sm text-gray-500 text-center">
{$t('booking.noUsersFound')}
</div>
{:else}
{#each filteredUsers as userItem}
<button
type="button"
on:click={() => selectUser(userItem)}
class="w-full text-left p-3 hover:bg-indigo-50 transition-colors border-b border-gray-50 last:border-0"
>
<p class="text-sm font-bold text-gray-800">{userItem.name || userItem.email}</p>
<p class="text-xs text-gray-500">{userItem.email}</p>
</button>
{/each}
{/if}
</div>
{/if}
{#if showUserDropdown}
<div
class="fixed inset-0 z-10"
on:click={() => showUserDropdown = false}
></div>
{/if}
</div>
{/if}
<div class="mt-2">
<label class="block text-xs font-bold text-gray-500 mb-1">{$t('booking.note')}</label>
<input
type="text"
bind:value={note}
placeholder={$t('booking.notePlaceholder')}
class="w-full p-2.5 bg-white border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
</div>
{/if}
<div>
<label class="block text-sm font-bold text-gray-700 mb-1 flex items-center gap-2">
<Calendar size={16} />
{$t('booking.bookingDate')}
</label>
<div class="relative">
<input
type="date"
bind:value={bookingDate}
min={new Date().toISOString().split('T')[0]}
class="absolute inset-0 opacity-0 cursor-pointer w-full z-10"
on:click={(e) => e.currentTarget.showPicker()}
/>
<div class="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl font-medium text-gray-700 flex justify-between items-center shadow-sm">
<span>{format(new Date(bookingDate + 'T00:00:00'), dateFormat, { locale: dateLocale })}</span>
<Calendar size={18} class="text-indigo-600" />
</div>
</div>
</div>
{#if space.type === 'MEETING_ROOM'}
<div class="space-y-4">
<label class="block text-sm font-bold text-gray-700 mb-1 flex items-center gap-2">
<Clock size={16} />
{$t('booking.availabilityFor', { values: { date: format(new Date(bookingDate), dateFormat, { locale: dateLocale }) } })}
</label>
{#if loadingReservations}
<div class="flex justify-center py-8">
<Loader2 class="animate-spin text-indigo-500" size={32} />
</div>
{:else}
<div class="grid grid-cols-4 sm:grid-cols-6 gap-2 max-h-60 overflow-y-auto p-1 border rounded-xl bg-gray-50">
{#each timeOptions as time}
{@const reservation = isSlotBooked(time)}
<div class="relative group">
<button
type="button"
on:click={() => selectSlot(time)}
disabled={(!!reservation && reservation.userId !== $user?.id && reservation.user?.id !== $user?.id && $user?.role !== 'ADMIN') || (reservation && reservation.isPast)}
class="w-full py-2 text-[10px] font-bold rounded-md transition-all border
{reservation?.isPast ? 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed' :
reservation ? 'bg-red-100 text-red-700 border-red-200' :
startTime === time ? 'bg-indigo-600 text-white border-indigo-700 ring-2 ring-indigo-200' :
'bg-white text-gray-600 border-gray-200 hover:border-indigo-400'}"
title={reservation?.isPast ? $t('booking.past') : reservation ? $t('booking.bookedBy', { values: { user: reservation.user?.email || reservation.note || $t('booking.nonEmployee') } }) : $t('booking.availableAt', { values: { time: time } })}
>
{time}
</button>
{#if reservation && (reservation.userId === $user?.id || reservation.user?.id === $user?.id || $user?.role === 'ADMIN')}
<button
on:click|stopPropagation={() => handleDelete(reservation.id)}
class="absolute -top-1 -right-1 p-0.5 bg-red-600 text-white rounded-full shadow-sm opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={10} />
</button>
{/if}
</div>
{/each}
</div>
{/if}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-bold text-gray-700 mb-1">
{$t('booking.startTime')}
</label>
<select bind:value={startTime} class="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none font-medium">
{#each timeOptions as time}
<option value={time}>{time}</option>
{/each}
</select>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-1">
{$t('booking.endTime')}
</label>
<select bind:value={endTime} class="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none font-medium">
{#each timeOptions as time}
<option value={time}>{time}</option>
{/each}
</select>
</div>
</div>
</div>
{:else}
<div class="bg-indigo-50 border border-indigo-100 rounded-xl p-4">
<p class="text-indigo-800 text-sm font-medium">{$t('booking.deskNote')}</p>
</div>
{/if}
<button
on:click={handleBook}
disabled={loading}
class="w-full py-4 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-colors shadow-lg shadow-indigo-100 flex items-center justify-center gap-2 disabled:opacity-50 mt-4"
>
{#if loading}
<Loader2 class="animate-spin" size={20} />
{$t('booking.booking')}
{:else}
{$t('booking.confirm')}
{/if}
</button>
</div>
{/if}
</div>
</div>
</div>

View File

@ -1,234 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { io, Socket } from 'socket.io-client';
import { Laptop, Bath, Coffee, Users, AlertCircle } from 'lucide-svelte';
import BookingModal from './BookingModal.svelte';
import Avatar from './Avatar.svelte';
import { resolveAssetUrl } from '$lib/utils/url';
import { t } from 'svelte-i18n';
export let floorId: string;
export let spaces: any[] = [];
export let planImageUrl: string | null = null;
export let selectedDate: Date = new Date();
let container: HTMLDivElement;
let socket: Socket;
let spaceStatus: Record<string, any> = {};
let selectedSpace: any = null;
let containerWidth = 800;
let containerHeight = 600;
// Track if we have received any real-time updates to avoid overwriting them with stale initial status
let realtimeUpdates: Record<string, any> = {};
let lastSelectedDateString = selectedDate.toDateString();
$: isToday = new Date(selectedDate.getTime()).setHours(0,0,0,0) === new Date().setHours(0,0,0,0);
$: {
// Detect when the actual day changes to clear real-time updates
const currentDateString = selectedDate.toDateString();
if (currentDateString !== lastSelectedDateString) {
realtimeUpdates = {};
lastSelectedDateString = currentDateString;
}
// Reset spaceStatus and ensure we re-calculate from new spaces prop
let newStatus: Record<string, any> = {};
spaces.forEach(s => {
// Don't overwrite if we already have a real-time update for this view
if (realtimeUpdates[s.id]) {
newStatus[s.id] = realtimeUpdates[s.id];
return;
}
const activeReservation = s.reservations && s.reservations.length > 0 ? s.reservations[0] : null;
newStatus[s.id] = {
isOccupied: !!activeReservation,
reservation: activeReservation
};
});
spaceStatus = newStatus;
updateContainerSizeToFitAll();
}
function updateContainerSizeToFitAll() {
if (spaces.length === 0) return;
let maxR = 800;
let maxB = 600;
spaces.forEach(s => {
const w = s.width || (s.type === 'DESK' ? 12 : 20);
const h = s.height || (s.type === 'DESK' ? 8 : 15);
const r = ((s.x + w) / 100) * 800; // Use 800 as base or whatever
const b = ((s.y + h) / 100) * 600;
if (r > maxR) maxR = r;
if (b > maxB) maxB = b;
});
let maxXP = 100;
let maxYP = 100;
spaces.forEach(s => {
const w = s.width || (s.type === 'DESK' ? 12 : 20);
const h = s.height || (s.type === 'DESK' ? 8 : 15);
if (s.x + w > maxXP) maxXP = s.x + w;
if (s.y + h > maxYP) maxYP = s.y + h;
});
containerWidth = (maxXP / 100) * 800;
containerHeight = (maxYP / 100) * 600;
// Add some padding
containerWidth += 40;
containerHeight += 40;
}
onMount(() => {
const socketUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
socket = io(socketUrl, {
transports: ['websocket'], // Prefer WebSockets
});
socket.on('connect', () => {
console.log('Connected to WebSocket for floor:', floorId);
socket.emit('join_floor', floorId);
});
socket.on('space_status_changed', (data: { spaceId: string, isOccupied: boolean, reservation?: any }) => {
console.log('Space status changed received:', data);
let isRelevant = false;
if (data.reservation) {
const start = new Date(data.reservation.startTime);
const end = new Date(data.reservation.endTime);
const viewStart = new Date(selectedDate.getTime());
viewStart.setHours(0, 0, 0, 0);
const viewEnd = new Date(selectedDate.getTime());
viewEnd.setHours(23, 59, 59, 999);
isRelevant = (start <= viewEnd && end >= viewStart);
} else {
// If we don't have reservation data (should not happen with updated API),
// fallback to isToday check.
isRelevant = isToday;
}
if (!isRelevant) {
console.log('Ignoring real-time update: not relevant to current view', {
selectedDate: selectedDate.toDateString(),
reservation: data.reservation ? {
start: new Date(data.reservation.startTime).toDateString(),
end: new Date(data.reservation.endTime).toDateString()
} : 'none'
});
return;
}
const update = {
isOccupied: data.isOccupied,
reservation: data.reservation
};
// Save to realtimeUpdates to prevent overwriting by API re-fetches
realtimeUpdates[data.spaceId] = update;
spaceStatus[data.spaceId] = update;
spaceStatus = { ...spaceStatus }; // Trigger reactivity
});
});
onDestroy(() => {
if (socket) {
socket.emit('leave_floor', floorId);
socket.disconnect();
}
});
function getSpaceTypeIcon(type: string, name: string) {
if (type === 'DESK') return Laptop;
if (type === 'MEETING_ROOM') return Users;
const lowerName = name.toLowerCase();
if (lowerName.includes('toilet') || lowerName.includes('bath')) return Bath;
if (lowerName.includes('coffee')) return Coffee;
return AlertCircle;
}
function handleSpaceClick(space: any) {
if (space.type === 'AMENITY') return;
selectedSpace = space;
}
</script>
<div
bind:this={container}
class="relative bg-white shadow-xl rounded-lg overflow-hidden border mx-auto floorplan-container"
style="width: {containerWidth}px; height: {containerHeight}px; background-image: url({resolveAssetUrl(planImageUrl)}); background-size: cover; background-repeat: no-repeat; background-position: center;"
>
{#each spaces as space (space.id)}
{@const status = spaceStatus[space.id] || { isOccupied: false }}
<button
on:click={() => handleSpaceClick(space)}
class="absolute p-2 border-2 rounded-lg shadow-md transition-all overflow-hidden flex items-center justify-center {space.type !== 'AMENITY' ? 'hover:scale-105 cursor-pointer' : 'cursor-default opacity-80'} {status.isOccupied ? 'bg-yellow-50 border-yellow-500' : 'bg-green-50 border-green-500'}"
style="left: 0; top: 0; width: {(space.width / 100) * containerWidth}px; height: {(space.height / 100) * containerHeight}px; transform: translate({(space.x / 100) * containerWidth}px, {(space.y / 100) * containerHeight}px)"
title="{space.name} ({status.isOccupied ? $t('booking.currentlyBooked') : $t('booking.available')})"
>
<div
class="flex items-center gap-1 shrink-0 justify-center"
style="
transform: rotate({space.rotation || 0}deg);
width: {(space.rotation % 180 === 90) ? (space.height / 100) * containerHeight : (space.width / 100) * containerWidth}px;
height: {(space.rotation % 180 === 90) ? (space.width / 100) * containerWidth : (space.height / 100) * containerHeight}px;
"
>
<div class="relative">
<div class={status.isOccupied ? 'text-yellow-600' : 'text-green-600'}>
<svelte:component this={getSpaceTypeIcon(space.type, space.name)} size={16} />
</div>
{#if status.isOccupied && space.type === 'DESK'}
<div class="absolute -top-4 -left-4 shadow-sm" style="transform: rotate(-{space.rotation || 0}deg);">
<Avatar
user={status.reservation.user}
size="sm"
className="border-yellow-500 bg-white"
/>
</div>
{/if}
</div>
<div class="flex flex-col items-start leading-none">
<span class="text-[9px] font-bold text-gray-800 whitespace-nowrap">
{space.name}
</span>
{#if status.isOccupied && space.type === 'DESK'}
<span class="text-[7px] text-gray-500 truncate max-w-[40px]">
{status.reservation?.user?.name || status.reservation?.user?.email.split('@')[0]}
</span>
{/if}
</div>
</div>
</button>
{/each}
</div>
{#if selectedSpace}
<BookingModal
space={selectedSpace}
status={spaceStatus[selectedSpace.id] || { isOccupied: false }}
selectedDate={selectedDate}
on:close={() => selectedSpace = null}
/>
{/if}
<style>
.floorplan-container {
background-color: #f8fafc;
background-image:
linear-gradient(to right, #e2e8f0 1px, transparent 1px),
linear-gradient(to bottom, #e2e8f0 1px, transparent 1px);
background-size: 20px 20px;
}
</style>

View File

@ -1,97 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { io, Socket } from 'socket.io-client';
export let spaceId: string;
export let name: string;
export let type: string;
export let floorId: string;
export let initialStatus: boolean = false; // true if occupied
export let selectedDate: Date = new Date();
let isOccupied = initialStatus;
let socket: Socket;
// Track if we have received any real-time updates for this space
let realtimeOccupied: boolean | null = null;
let lastSelectedDateString = selectedDate.toDateString();
$: isToday = new Date(selectedDate.getTime()).setHours(0,0,0,0) === new Date().setHours(0,0,0,0);
$: if (initialStatus !== undefined) {
// Detect when the actual day changes to clear real-time updates
const currentDateString = selectedDate.toDateString();
if (currentDateString !== lastSelectedDateString) {
realtimeOccupied = null;
lastSelectedDateString = currentDateString;
}
if (realtimeOccupied !== null) {
isOccupied = realtimeOccupied;
} else {
isOccupied = initialStatus;
}
}
onMount(() => {
// Connect to WebSocket using the environment variable for the backend URL.
const socketUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
socket = io(socketUrl, {
transports: ['websocket'],
});
socket.on('connect', () => {
console.log('Connected to WebSocket (SpaceCard)');
socket.emit('join_floor', floorId);
});
socket.on('space_status_changed', (data: { spaceId: string, isOccupied: boolean, reservation?: any }) => {
if (data.spaceId !== spaceId) return;
let isRelevant = false;
if (data.reservation) {
const start = new Date(data.reservation.startTime);
const end = new Date(data.reservation.endTime);
const viewStart = new Date(selectedDate.getTime());
viewStart.setHours(0, 0, 0, 0);
const viewEnd = new Date(selectedDate.getTime());
viewEnd.setHours(23, 59, 59, 999);
isRelevant = (start <= viewEnd && end >= viewStart);
} else {
isRelevant = isToday;
}
if (isRelevant) {
console.log('Space status updated for:', spaceId, data.isOccupied);
realtimeOccupied = data.isOccupied;
isOccupied = data.isOccupied;
}
});
socket.on('disconnect', () => {
console.log('Disconnected from WebSocket');
});
});
onDestroy(() => {
if (socket) {
socket.emit('leave_floor', floorId);
socket.disconnect();
}
});
</script>
<div class="p-4 border rounded shadow-md {isOccupied ? 'bg-red-100 border-red-500' : 'bg-green-100 border-green-500'}">
<div class="flex justify-between items-center">
<div>
<h3 class="font-bold text-lg">{name}</h3>
<p class="text-sm text-gray-600">{type}</p>
</div>
<div class="flex flex-col items-end">
<span class="px-2 py-1 rounded text-xs font-semibold {isOccupied ? 'bg-red-500 text-white' : 'bg-green-500 text-white'}">
{isOccupied ? 'Occupied' : 'Available'}
</span>
</div>
</div>
</div>

View File

@ -1,365 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { X, Plus, Trash2, Building2 } from 'lucide-svelte';
import { apiFetch } from '$lib/api/client';
import { resolveAssetUrl } from '$lib/utils/url';
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 imageFile: File | null = null;
let fileInput: HTMLInputElement;
let floors = building?.floors ? JSON.parse(JSON.stringify(building.floors)) : [];
let loading = false;
let error = '';
let showDangerZone = false;
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
$: {
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 = '';
imageFile = null;
floors = [];
}
}
function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
if (file.size > MAX_FILE_SIZE) {
error = "File size exceeds 10MB limit";
target.value = "";
return;
}
imageFile = file;
// If a file is selected, clear the imageUrl to avoid ambiguity
imageUrl = "";
error = "";
}
}
function handleUrlChange() {
// If the user starts typing a URL, clear the file selection
if (imageFile) {
imageFile = null;
if (fileInput) {
fileInput.value = "";
}
}
}
// Reactive preview URL
$: previewUrl = imageFile ? URL.createObjectURL(imageFile) : resolveAssetUrl(imageUrl);
async function saveBuilding() {
loading = true;
error = '';
try {
let savedBuilding;
if (building) {
if (imageFile) {
const formData = new FormData();
formData.append('file', imageFile);
const assetResponse = await apiFetch<any>(`/assets/building/${building.id}/image`, {
method: 'POST',
body: formData,
});
imageUrl = assetResponse.imageUrl;
}
savedBuilding = await apiFetch(`/spaces/buildings/${building.id}`, {
method: 'PATCH',
body: JSON.stringify({
name,
description: description || null,
imageUrl: imageUrl || ""
})
});
// 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<any>('/spaces/buildings', {
method: 'POST',
body: JSON.stringify({
name,
description: description || null,
imageUrl: imageUrl || ""
})
});
if (imageFile) {
const formData = new FormData();
formData.append('file', imageFile);
const assetResponse = await apiFetch<any>(`/assets/building/${savedBuilding.id}/image`, {
method: 'POST',
body: formData,
});
// Re-update the building with the new image URL returned from asset upload
savedBuilding = await apiFetch(`/spaces/buildings/${savedBuilding.id}`, {
method: 'PATCH',
body: JSON.stringify({
imageUrl: assetResponse.imageUrl
})
});
}
// 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 })
});
}
}
imageFile = null;
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">Building Image</label>
<div class="mt-1 flex items-center space-x-4">
<input
type="file"
accept="image/*"
bind:this={fileInput}
on:change={handleFileChange}
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100"
/>
</div>
<p class="mt-2 text-sm text-gray-500">Upload an image for this building (max 10MB).</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Header Image URL (Optional)</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 previewUrl}
<img src={previewUrl} 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}
on:input={handleUrlChange}
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

@ -1,515 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import interact from 'interactjs';
import { Laptop, Bath, Coffee, Users, Save, Upload, RotateCw } from 'lucide-svelte';
import { apiFetch } from '$lib/api/client';
import { resolveAssetUrl } from '$lib/utils/url';
export let floorId: string;
export let initialSpaces: any[] = [];
export let planImageUrl: string | null = null;
let container: HTMLDivElement;
let spaces = [...initialSpaces];
let containerWidth = 800;
let containerHeight = 600;
// Keep local spaces in sync if initialSpaces changes from parent (e.g. after full page load)
$: if (initialSpaces) {
// Only re-sync if the number of spaces changed or they are all new
// to avoid wiping out unsaved local changes while editing.
// In most cases, initialSpaces only changes on mount or full refresh.
if (spaces.length === 0 || (initialSpaces.length > 0 && !initialSpaces[0].id.startsWith('temp-') && spaces[0]?.id?.startsWith('temp-'))) {
spaces = [...initialSpaces];
updateContainerSizeToFitAll();
}
}
function updateContainerSizeToFitAll() {
if (spaces.length === 0) return;
let maxR = 800;
let maxB = 600;
spaces.forEach(s => {
const w = s.width || (s.type === 'DESK' ? 12 : 20);
const h = s.height || (s.type === 'DESK' ? 8 : 15);
// Calculate right and bottom in pixels relative to 800x600 (or current if it's the master)
// Actually, since we're starting from percentages, we should probably pick a base pixel size.
// Let's use 800 as the base width.
const r = ((s.x + w) / 100) * containerWidth;
const b = ((s.y + h) / 100) * containerHeight;
if (r > maxR) maxR = r;
if (b > maxB) maxB = b;
});
containerWidth = Math.max(800, Math.ceil(maxR + 40)); // +40 for some padding
containerHeight = Math.max(600, Math.ceil(maxB + 40));
}
let toolboxItems = [
{ type: 'DESK', icon: Laptop, label: 'Desk' },
{ type: 'MEETING_ROOM', icon: Users, label: 'Meeting Room' },
{ 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;
function interactAction(node: HTMLElement, space: any) {
const isDesk = space.type === 'DESK';
const interactable = interact(node);
interactable.draggable({
inertia: true,
allowFrom: '.drag-handle',
modifiers: [
interact.modifiers.restrictRect({
restriction: 'parent',
endOnly: true
}),
interact.modifiers.snap({
targets: [
interact.snappers.grid({ x: 20, y: 20 })
],
range: Infinity,
relativePoints: [ { x: 0, y: 0 } ],
offset: 'parent'
})
],
autoScroll: true,
listeners: {
move: dragMoveListener,
end: onDragEnd
}
});
if (!isDesk) {
interactable.resizable({
edges: { right: '.resize-handle', bottom: '.resize-handle' },
modifiers: [
interact.modifiers.restrictSize({
min: { width: 40, height: 40 }
}),
interact.modifiers.restrictRect({
restriction: 'parent'
}),
interact.modifiers.snap({
targets: [
interact.snappers.grid({ x: 20, y: 20 })
],
range: Infinity,
offset: 'parent'
})
],
listeners: {
move: resizeMoveListener,
end: onResizeEnd
}
});
}
return {
destroy() {
interactable.unset();
}
};
}
function dragMoveListener(event: any) {
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 resizeMoveListener(event: any) {
const target = event.target;
let x = parseFloat(target.getAttribute('data-x')) || 0;
let y = parseFloat(target.getAttribute('data-y')) || 0;
target.style.width = event.rect.width + 'px';
target.style.height = event.rect.height + 'px';
x += event.deltaRect.left;
y += event.deltaRect.top;
target.style.transform = `translate(${x}px, ${y}px)`;
target.setAttribute('data-x', x);
target.setAttribute('data-y', y);
}
function onDragEnd(event: any) {
updateSpaceDimensions(event.target);
}
function onResizeEnd(event: any) {
updateSpaceDimensions(event.target);
}
function updateSpaceDimensions(target: any) {
const id = target.getAttribute('data-id');
const x = parseFloat(target.getAttribute('data-x')) || 0;
const y = parseFloat(target.getAttribute('data-y')) || 0;
const width = target.offsetWidth;
const height = target.offsetHeight;
if (!containerWidth || !containerHeight) return; // Prevent division by zero
// If the element is outside the current container, grow it!
const right = x + width;
const bottom = y + height;
const oldWidth = containerWidth;
const oldHeight = containerHeight;
let needsResize = false;
if (right > containerWidth - 20) {
containerWidth = Math.ceil(right + 40);
needsResize = true;
}
if (bottom > containerHeight - 20) {
containerHeight = Math.ceil(bottom + 40);
needsResize = true;
}
if (needsResize) {
spaces = spaces.map(s => {
// Recalculate all percentages based on new container size to keep pixel positions same
return {
...s,
x: (s.x * oldWidth) / containerWidth,
y: (s.y * oldHeight) / containerHeight,
width: (s.width * oldWidth) / containerWidth,
height: (s.height * oldHeight) / containerHeight
};
});
}
// Convert current target to percentages based on the (potentially new) container size
const xPercent = (x / containerWidth) * 100;
const yPercent = (y / containerHeight) * 100;
const widthPercent = (width / containerWidth) * 100;
const heightPercent = (height / containerHeight) * 100;
if (isNaN(xPercent) || isNaN(yPercent) || isNaN(widthPercent) || isNaN(heightPercent)) return;
const index = spaces.findIndex(s => s.id === id);
if (index !== -1) {
spaces[index].x = xPercent;
spaces[index].y = yPercent;
spaces[index].width = widthPercent;
spaces[index].height = heightPercent;
spaces = [...spaces];
}
}
function addSpace(item: any) {
const newSpace = {
id: `temp-${Date.now()}`,
name: `${item.label} ${spaces.length + 1}`,
type: item.type,
x: 5,
y: 5,
width: item.type === 'DESK' ? 12 : 20, // Default widths as percentage
height: item.type === 'DESK' ? 8 : 15, // Default heights as percentage
rotation: 0,
floorId
};
spaces = [...spaces, newSpace];
}
async function handleFileUpload(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) {
alert("File size exceeds 10MB limit");
target.value = "";
return;
}
const formData = new FormData();
formData.append('file', file);
try {
// Upload binary asset
const updatedFloor = await apiFetch<any>(`/assets/floor/${floorId}/plan`, {
method: 'POST',
body: formData
});
// Update local plan image URL (which should be the served asset URL)
planImageUrl = updatedFloor.planImageUrl;
alert('Floorplan uploaded successfully!');
} catch (err: any) {
console.error('Failed to upload floor plan', err);
alert(err.message || 'Failed to upload floor plan');
}
}
async function saveLayout() {
saving = true;
try {
const response = await apiFetch<any[]>('/spaces/batch-update', {
method: 'POST',
body: JSON.stringify(spaces)
});
// Update local spaces with returned data (especially for new IDs)
if (Array.isArray(response)) {
spaces = response;
}
alert('Layout saved successfully!');
} catch (err) {
console.error('Save failed', err);
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 rotateSpace(id: string) {
const index = spaces.findIndex(s => s.id === id);
if (index !== -1) {
const space = spaces[index];
const oldWidth = space.width;
const oldHeight = space.height;
// Swap width and height
spaces[index].width = oldHeight * (containerHeight / containerWidth);
spaces[index].height = oldWidth * (containerWidth / containerHeight);
// Update rotation
spaces[index].rotation = ((spaces[index].rotation || 0) + 90) % 360;
spaces = [...spaces];
}
}
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>
<div class="flex-1 p-8 overflow-auto bg-gray-200 flex justify-center items-start">
<div
bind:this={container}
class="relative bg-white shadow-xl rounded-lg overflow-hidden floorplan-container"
style="width: {containerWidth}px; height: {containerHeight}px; background-image: url({resolveAssetUrl(planImageUrl)}); background-size: cover; background-repeat: no-repeat; background-position: center;"
>
{#if !planImageUrl}
<div class="absolute inset-0 flex flex-col items-center justify-center text-gray-400 pointer-events-none">
<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
use:interactAction={space}
class="draggable-space absolute p-2 bg-white border-2 border-blue-500 rounded-lg shadow-lg group overflow-hidden flex items-center justify-center {space.type !== 'DESK' ? 'resizable-space' : ''}"
style="left: 0; top: 0; width: {(space.width / 100) * containerWidth}px; height: {(space.height / 100) * containerHeight}px; transform: translate({(space.x / 100) * containerWidth}px, {(space.y / 100) * containerHeight}px)"
data-id={space.id}
data-x={(space.x / 100) * containerWidth}
data-y={(space.y / 100) * containerHeight}
>
<div
class="flex flex-col drag-handle cursor-move shrink-0 items-center justify-center"
style="
transform: rotate({space.rotation || 0}deg);
width: {(space.rotation % 180 === 90) ? (space.height / 100) * containerHeight : (space.width / 100) * containerWidth}px;
height: {(space.rotation % 180 === 90) ? (space.width / 100) * containerWidth : (space.height / 100) * containerHeight}px;
"
>
<div class="flex items-center gap-2 mb-1">
<div class="text-blue-600 shrink-0">
{#if space.type === 'DESK'}
<Laptop size={16} />
{:else if space.type === 'MEETING_ROOM'}
<Users size={16} />
{:else if space.subtype === 'TOILET'}
<Bath size={16} />
{:else if space.subtype === 'COFFEE'}
<Coffee size={16} />
{:else}
<div class="w-4 h-4 bg-gray-400 rounded-full"></div>
{/if}
</div>
{#if editingId === space.id}
<input
type="text"
bind:value={space.name}
on:blur={stopEditing}
on:keydown={(e) => handleKeydown(e, space.id)}
on:click|stopPropagation
class="text-[10px] font-bold text-gray-700 border border-blue-300 outline-none bg-blue-50 rounded px-1 w-full"
autofocus
/>
{:else}
<span
on:click|stopPropagation={() => startEditing(space.id)}
title="Click to rename"
class="text-[10px] font-bold text-gray-700 select-none truncate hover:bg-gray-100 px-1 rounded cursor-text"
>
{space.name}
</span>
{#if space.type === 'DESK'}
<button
on:click|stopPropagation={() => rotateSpace(space.id)}
class="hidden group-hover:flex items-center justify-center shrink-0 w-4 h-4 text-white bg-blue-500 rounded-full hover:bg-blue-600 ml-auto"
title="Rotate Desk"
style="transform: rotate(-{space.rotation || 0}deg);"
>
<RotateCw size={10} />
</button>
<button
on:click|stopPropagation={() => removeSpace(space.id)}
class="hidden group-hover:flex items-center justify-center shrink-0 w-4 h-4 text-white bg-red-500 rounded-full hover:bg-red-600 ml-1"
style="transform: rotate(-{space.rotation || 0}deg);"
>
×
</button>
{:else}
<button
on:click|stopPropagation={() => removeSpace(space.id)}
class="hidden group-hover:flex items-center justify-center shrink-0 w-4 h-4 text-white bg-red-500 rounded-full hover:bg-red-600 ml-auto"
style="transform: rotate(-{space.rotation || 0}deg);"
>
×
</button>
{/if}
{/if}
</div>
{#if space.type !== 'DESK'}
<div class="flex-1 border border-dashed border-gray-200 rounded flex items-center justify-center text-[8px] text-gray-400 overflow-hidden relative pointer-events-none">
{space.type.replace('_', ' ')}
</div>
{/if}
</div>
{#if space.type !== 'DESK'}
<!-- Explicit resize handle OUTSIDE drag-handle -->
<div class="resize-handle absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize z-10 flex items-center justify-center">
<div class="w-2 h-2 border-b-2 border-r-2 border-blue-400 opacity-50"></div>
</div>
{/if}
</div>
{/each}
</div>
</div>
</div>
</div>
<style>
.draggable-space {
touch-action: none;
user-select: none;
}
.resize-handle {
background: linear-gradient(135deg, transparent 50%, rgba(59, 130, 246, 0.1) 50%, rgba(59, 130, 246, 0.1) 100%);
border-radius: 0 0 4px 0;
}
.resize-handle:hover {
background: linear-gradient(135deg, transparent 50%, rgba(59, 130, 246, 0.3) 50%, rgba(59, 130, 246, 0.3) 100%);
}
.floorplan-container:not([style*="background-image: url()"]):not([style*='background-image: url("")']):not([style*="background-image: url(null)"]) {
/* When an image is set, we don't need the grid if we want it to be clean,
but the instruction says "if layout isn't configured yet".
If planImageUrl is null or empty, we'll show the grid.
*/
}
.floorplan-container {
background-color: #f8fafc;
background-image:
linear-gradient(to right, #e2e8f0 1px, transparent 1px),
linear-gradient(to bottom, #e2e8f0 1px, transparent 1px);
background-size: 20px 20px;
}
</style>

View File

@ -1,11 +0,0 @@
import { register, init, getLocaleFromNavigator } from 'svelte-i18n';
register('en', () => import('./locales/en.json'));
register('es-ES', () => import('./locales/es-ES.json'));
export function setupI18n(initialLocale = 'en') {
init({
fallbackLocale: 'en',
initialLocale: initialLocale || getLocaleFromNavigator() || 'en',
});
}

View File

@ -1,173 +0,0 @@
{
"nav": {
"organization": "Organization",
"users": "Users",
"reports": "Reports",
"dashboard": "Dashboard",
"myBookings": "My Bookings",
"profile": "Profile",
"login": "Login"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"delete": "Delete",
"actions": "Actions",
"loading": "Loading...",
"language": "Language"
},
"organization": {
"settings": "Organization Settings",
"name": "Organization Name",
"logo": "Organization Logo",
"updateSuccess": "Organization updated successfully"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back",
"selectBuilding": "Select a building to see available floors",
"noBuildings": "No buildings found",
"noBuildingsDesc": "Your organization doesn't have any buildings assigned yet.",
"addBuilding": "Add Building",
"viewBuilding": "View Building",
"floors": "{count, plural, =1 {1 Floor} other {# Floors}}",
"loading": "Loading buildings..."
},
"building": {
"back": "Back to Dashboard",
"loading": "Loading building details...",
"selectDate": "Select Date",
"selectFloor": "Select Floor",
"floor": "Floor {number}",
"editFloorplan": "Edit Floorplan",
"switching": "Switching floor...",
"interactiveView": "Interactive View - Click a space to see details or book.",
"noFloorplan": "No floorplan image uploaded. Showing default grid.",
"selectToSee": "Select a floor to see the layout",
"notFound": "Building not found",
"notFoundDesc": "The building you're looking for doesn't exist or you don't have access.",
"return": "Return to Dashboard",
"selectDate": "Select date"
},
"booking": {
"available": "Available",
"book": "Book",
"cancel": "Cancel",
"confirm": "Confirm Booking",
"startTime": "Start Time",
"endTime": "End Time",
"date": "Date",
"delete": "Delete Booking",
"confirmDelete": "Confirm Delete",
"adminWarning": "Warning: Admin Action",
"adminDeleteInfo": "You are about to delete another user's booking. This action cannot be undone.",
"unavailable": "This space is currently unavailable for booking.",
"bookingDate": "Booking Date",
"availabilityFor": "Availability for {date}",
"bookedBy": "Booked by {user}",
"availableAt": "Available at {time}",
"room": "Room",
"desk": "Desk",
"amenity": "Amenity",
"currentlyBooked": "Currently Booked",
"editBooking": "Edit Booking",
"deskNote": "Desks are booked for the full day.",
"booking": "Booking...",
"errorCreating": "Failed to create booking",
"errorDeleting": "Failed to delete booking",
"errorTime": "End time must be after start time",
"errorPast": "Cannot book in the past",
"past": "This time has already passed",
"bookFor": "Book for",
"myself": "Myself",
"someoneElse": "Someone else / Event",
"selectUser": "Select user",
"nonEmployee": "Non-employee / Event (no user)",
"note": "Note",
"notePlaceholder": "Reason for booking, name of guest, event name, etc.",
"searchUserPlaceholder": "Search by name or email...",
"noUsersFound": "No users found",
"spaceType": {
"DESK": "Desk",
"MEETING_ROOM": "Meeting Room",
"AMENITY": "Amenity"
}
},
"myBookings": {
"title": "My Bookings",
"description": "Manage your upcoming and past reservations.",
"noBookings": "No bookings found",
"noBookingsDesc": "You haven't made any reservations yet.",
"goToDashboard": "Go to dashboard to book a space",
"cancelConfirm": "Are you sure you want to cancel this booking?",
"cancelFailed": "Failed to cancel booking",
"updateFailed": "Failed to update booking",
"loadFailed": "Failed to load bookings",
"editBooking": "Edit Booking",
"space": "Space",
"floor": "Planta {number}",
"deskNote": "Note: Desk bookings are full-day. Times will be automatically adjusted to encompass the entire day.",
"saving": "Saving...",
"editBookingTitle": "Edit Booking",
"saveChanges": "Save Changes",
"upcoming": "Upcoming Bookings",
"past": "Past Bookings",
"showPast": "Show past bookings",
"hidePast": "Hide past bookings"
},
"reports": {
"title": "Admin Reporting Dashboard",
"downloadData": "Download XLSX Data",
"overallUtilization": "Overall Utilization",
"totalSpaces": "Total Spaces",
"occupiedNow": "{count} currently occupied",
"totalEmployees": "Total Employees",
"acrossRoles": "Across all roles",
"utilizationByBuilding": "Utilization by Building",
"utilizationBySpaceType": "Utilization by Space Type",
"employeeRoleDistribution": "Employee Role Distribution",
"topBookers": "Top Bookers (Most Reservations)",
"noBookingsFound": "No bookings found yet.",
"bookingsCount": "{count, plural, =1 {1 booking} other {# bookings}}",
"exportFailed": "Export failed",
"downloadError": "Error downloading report: {error}"
},
"users": {
"title": "User Management",
"invite": {
"role": "Role",
"createLink": "Create Invite Link",
"generated": "Invitation Link Generated",
"description": "Anyone with this link can register as a <strong>{role}</strong> in your organization.",
"copy": "Copy",
"copied": "Copied!",
"done": "Done",
"error": "Error generating invite: {error}"
},
"table": {
"user": "User",
"role": "Role",
"joined": "Joined",
"actions": "Actions",
"id": "ID"
},
"roles": {
"EMPLOYEE": "Employee",
"SUPERVISOR": "Supervisor",
"ADMIN": "Admin"
},
"password": {
"change": "Change Password",
"title": "Change Password",
"description": "Set a new password for <strong>{email}</strong>",
"new": "New Password",
"placeholder": "At least 6 characters",
"save": "Save Password",
"success": "Password updated successfully",
"error": "Error updating password: {error}"
},
"updateRoleError": "Error updating role: {error}",
"loading": "Loading users..."
}
}

View File

@ -1,173 +0,0 @@
{
"nav": {
"organization": "Organización",
"users": "Usuarios",
"reports": "Informes",
"dashboard": "Panel de control",
"myBookings": "Mis reservas",
"profile": "Perfil",
"login": "Iniciar sesión"
},
"common": {
"save": "Guardar",
"cancel": "Cancelar",
"edit": "Editar",
"delete": "Eliminar",
"actions": "Acciones",
"loading": "Cargando...",
"language": "Idioma"
},
"organization": {
"settings": "Configuración de la organización",
"name": "Nombre de la organización",
"logo": "Logotipo de la organización",
"updateSuccess": "Organización actualizada con éxito"
},
"dashboard": {
"title": "Panel de control",
"welcome": "Bienvenido/a de nuevo",
"selectBuilding": "Seleccione un edificio para ver las plantas disponibles",
"noBuildings": "No se han encontrado edificios",
"noBuildingsDesc": "Su organización aún no tiene edificios asignados.",
"addBuilding": "Añadir edificio",
"viewBuilding": "Ver edificio",
"floors": "{count, plural, =1 {1 Planta} other {# Plantas}}",
"loading": "Cargando edificios..."
},
"building": {
"back": "Volver al panel",
"loading": "Cargando detalles del edificio...",
"selectDate": "Seleccionar fecha",
"selectFloor": "Seleccionar planta",
"floor": "Planta {number}",
"editFloorplan": "Editar plano",
"switching": "Cambiando de planta...",
"interactiveView": "Vista interactiva - Haga clic en un espacio para ver detalles o reservar.",
"noFloorplan": "No se ha subido ninguna imagen del plano. Mostrando cuadrícula predeterminada.",
"selectToSee": "Seleccione una planta para ver la distribución",
"notFound": "Edificio no encontrado",
"notFoundDesc": "El edificio que busca no existe o no tiene acceso.",
"return": "Volver al panel",
"selectDate": "Seleccionar fecha"
},
"booking": {
"available": "Disponible",
"book": "Reservar",
"cancel": "Cancelar",
"confirm": "Confirmar reserva",
"startTime": "Hora de inicio",
"endTime": "Hora de finalización",
"date": "Fecha",
"delete": "Eliminar reserva",
"confirmDelete": "Confirmar eliminación",
"adminWarning": "Advertencia: Acción de administrador",
"adminDeleteInfo": "Está a punto de eliminar la reserva de otro usuario. Esta acción no se puede deshacer.",
"unavailable": "Este espacio no está disponible actualmente para reservar.",
"bookingDate": "Fecha de la reserva",
"availabilityFor": "Disponibilidad para {date}",
"bookedBy": "Reservado por {user}",
"availableAt": "Disponible a las {time}",
"room": "Sala",
"desk": "Puesto",
"amenity": "Servicio",
"currentlyBooked": "Reservado actualmente",
"editBooking": "Editar reserva",
"deskNote": "Los puestos se reservan para el día completo.",
"booking": "Reservando...",
"errorCreating": "Error al crear la reserva",
"errorDeleting": "Error al eliminar la reserva",
"errorTime": "La hora de finalización debe ser posterior a la de inicio",
"errorPast": "No se puede reservar en el pasado",
"past": "Esta hora ya ha pasado",
"bookFor": "Reservar para",
"myself": "Mí mismo/a",
"someoneElse": "Otra persona / Evento",
"selectUser": "Seleccionar usuario",
"nonEmployee": "No empleado / Evento (sin usuario)",
"note": "Nota",
"notePlaceholder": "Motivo de la reserva, nombre del invitado, nombre del evento, etc.",
"searchUserPlaceholder": "Buscar por nombre o email...",
"noUsersFound": "No se encontraron usuarios",
"spaceType": {
"DESK": "Puesto",
"MEETING_ROOM": "Sala de reuniones",
"AMENITY": "Servicio"
}
},
"myBookings": {
"title": "Mis reservas",
"description": "Gestione sus reservas próximas y pasadas.",
"noBookings": "No se han encontrado reservas",
"noBookingsDesc": "Aún no ha realizado ninguna reserva.",
"goToDashboard": "Ir al panel para reservar un espacio",
"cancelConfirm": "¿Está seguro/a de que desea cancelar esta reserva?",
"cancelFailed": "Error al cancelar la reserva",
"updateFailed": "Error al actualizar la reserva",
"loadFailed": "Error al cargar las reservas",
"editBooking": "Editar reserva",
"space": "Espacio",
"floor": "Planta {number}",
"deskNote": "Nota: Las reservas de puestos son para todo el día. Las horas se ajustarán automáticamente para abarcar el día completo.",
"saving": "Guardando...",
"editBookingTitle": "Editar reserva",
"saveChanges": "Guardar cambios",
"upcoming": "Próximas reservas",
"past": "Reservas pasadas",
"showPast": "Mostrar reservas pasadas",
"hidePast": "Ocultar reservas pasadas"
},
"reports": {
"title": "Panel de Control de Informes",
"downloadData": "Descargar Datos XLSX",
"overallUtilization": "Ocupación General",
"totalSpaces": "Total de Espacios",
"occupiedNow": "{count} ocupados actualmente",
"totalEmployees": "Total de Empleados",
"acrossRoles": "En todos los roles",
"utilizationByBuilding": "Ocupación por Edificio",
"utilizationBySpaceType": "Ocupación por Tipo de Espacio",
"employeeRoleDistribution": "Distribución de Empleados por Rol",
"topBookers": "Usuarios con más reservas",
"noBookingsFound": "Aún no se han encontrado reservas.",
"bookingsCount": "{count, plural, =1 {1 reserva} other {# reservas}}",
"exportFailed": "Error al exportar",
"downloadError": "Error al descargar el informe: {error}"
},
"users": {
"title": "Gestión de Usuarios",
"invite": {
"role": "Rol",
"createLink": "Crear enlace de invitación",
"generated": "Enlace de invitación generado",
"description": "Cualquier persona con este enlace puede registrarse como <strong>{role}</strong> en su organización.",
"copy": "Copiar",
"copied": "¡Copiado!",
"done": "Hecho",
"error": "Error al generar la invitación: {error}"
},
"table": {
"user": "Usuario",
"role": "Rol",
"joined": "Unido el",
"actions": "Acciones",
"id": "ID"
},
"roles": {
"EMPLOYEE": "Empleado",
"SUPERVISOR": "Supervisor",
"ADMIN": "Administrador"
},
"password": {
"change": "Cambiar contraseña",
"title": "Cambiar contraseña",
"description": "Establecer una nueva contraseña para <strong>{email}</strong>",
"new": "Nueva contraseña",
"placeholder": "Al menos 6 caracteres",
"save": "Guardar contraseña",
"success": "Contraseña actualizada con éxito",
"error": "Error al actualizar la contraseña: {error}"
},
"updateRoleError": "Error al actualizar el rol: {error}",
"loading": "Cargando usuarios..."
}
}

View File

@ -1,28 +0,0 @@
import { writable } from "svelte/store";
import { browser } from "$app/environment";
const initialUser = browser ? JSON.parse(localStorage.getItem("user") || "null") : null;
const initialToken = browser ? localStorage.getItem("token") : null;
export const user = writable(initialUser);
export const token = writable(initialToken);
export function logout() {
user.set(null);
token.set(null);
if (browser) {
localStorage.removeItem("user");
localStorage.removeItem("token");
}
}
if (browser) {
user.subscribe((value) => {
if (value) localStorage.setItem("user", JSON.stringify(value));
else localStorage.removeItem("user");
});
token.subscribe((value) => {
if (value) localStorage.setItem("token", value);
else localStorage.removeItem("token");
});
}

View File

@ -1,18 +0,0 @@
import { writable } from "svelte/store";
import { apiFetch } from "$lib/api/client";
export const organization = writable<{
id: string;
name: string;
logoUrl?: string;
language?: string;
} | null>(null);
export async function loadOrganization(orgId: string) {
try {
const org = await apiFetch<any>(`/organizations/${orgId}`);
organization.set(org);
} catch (e) {
console.error("Failed to load organization", e);
}
}

View File

@ -1,21 +0,0 @@
import CryptoJS from 'crypto-js';
export function getGravatarUrl(email: string, size = 100): string {
if (!email) return `https://www.gravatar.com/avatar/00000000000000000000000000000000?s=${size}&d=mp`;
const hash = CryptoJS.MD5(email.trim().toLowerCase()).toString();
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=mp`;
}
export function getInitials(name?: string, email?: string): string {
if (name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.trim().substring(0, 2).toUpperCase();
}
if (email) {
return email.trim().substring(0, 2).toUpperCase();
}
return '??';
}

View File

@ -1,15 +0,0 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
export function resolveAssetUrl(url: string | null | undefined): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('blob:') || url.startsWith('data:')) {
return url;
}
// If it starts with /assets/, prepend API_BASE_URL
if (url.startsWith('/assets/')) {
// Check if it's already a full URL (though startsWith /assets/ implies relative)
return `${API_BASE_URL}${url}`;
}
// Otherwise, return as is (could be a legacy relative path or something else)
return url;
}

View File

@ -1,68 +0,0 @@
<script lang="ts">
import "../app.css";
import { token, user } from "$lib/stores/auth";
import { organization, loadOrganization } from "$lib/stores/organization";
import { onMount } from "svelte";
import { resolveAssetUrl } from "$lib/utils/url";
import Avatar from "$lib/components/Avatar.svelte";
import { setupI18n } from "$lib/i18n";
import { t, locale, waitLocale } from "svelte-i18n";
setupI18n();
onMount(async () => {
if ($user?.organizationId) {
await loadOrganization($user.organizationId);
}
});
$: if ($organization?.language) {
locale.set($organization.language);
}
$: if ($user?.organizationId && !$organization) {
loadOrganization($user.organizationId);
}
</script>
{#await waitLocale()}
<div class="min-h-screen flex items-center justify-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
{:then}
<div class="min-h-screen bg-gray-50">
<nav class="bg-white shadow-sm px-4 py-3 flex justify-between items-center">
<div class="flex items-center space-x-2">
{#if $organization?.logoUrl}
<img src={resolveAssetUrl($organization.logoUrl)} alt="Logo" class="w-8 h-8 rounded-lg object-contain" />
{:else}
<div class="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
<span class="text-white font-bold">{$organization?.name?.charAt(0) || 'H'}</span>
</div>
{/if}
<h1 class="text-xl font-bold text-gray-800">{$organization?.name || 'Hot Desking'}</h1>
</div>
<div class="space-x-4 flex items-center">
{#if $token}
{#if $user?.role === 'ADMIN'}
<a href="/admin/organization" class="text-gray-600 hover:text-indigo-600 font-medium">{$t('nav.organization')}</a>
<a href="/admin/users" class="text-gray-600 hover:text-indigo-600 font-medium">{$t('nav.users')}</a>
<a href="/admin/reports" class="text-gray-600 hover:text-indigo-600 font-medium">{$t('nav.reports')}</a>
{/if}
<a href="/dashboard" class="text-gray-600 hover:text-indigo-600 font-medium">{$t('nav.dashboard')}</a>
<a href="/dashboard/my-bookings" class="text-gray-600 hover:text-indigo-600 font-medium">{$t('nav.myBookings')}</a>
<a href="/dashboard/profile" class="bg-indigo-50 text-indigo-700 px-3 py-1.5 rounded-md hover:bg-indigo-100 font-medium flex items-center space-x-2 transition-all">
<Avatar user={$user} size="sm" />
<span>{$t('nav.profile')}</span>
</a>
{:else}
<a href="/login" class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 font-medium transition-colors">{$t('nav.login')}</a>
{/if}
</div>
</nav>
<main class="max-w-7xl mx-auto p-6">
<slot />
</main>
</div>
{/await}

View File

@ -1,17 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { token } from "$lib/stores/auth";
import { goto } from "$app/navigation";
onMount(() => {
if ($token) {
goto("/dashboard");
} else {
goto("/login");
}
});
</script>
<div class="flex items-center justify-center min-h-[50vh]">
<p class="text-gray-500 animate-pulse font-medium">Redirecting...</p>
</div>

View File

@ -1,24 +0,0 @@
<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

@ -1,13 +0,0 @@
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

@ -1,200 +0,0 @@
<script lang="ts">
import { organization, loadOrganization } from "$lib/stores/organization";
import { user } from "$lib/stores/auth";
import { apiFetch } from "$lib/api/client";
import { onMount } from "svelte";
import { resolveAssetUrl } from "$lib/utils/url";
import { t } from "svelte-i18n";
let name = "";
let logoUrl = "";
let language = "en";
let logoFile: File | null = null;
let loading = false;
let success = false;
let error = "";
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
let logoUrlInput: HTMLInputElement;
let logoFileInput: HTMLInputElement;
onMount(async () => {
if ($user?.organizationId) {
await loadOrganization($user.organizationId);
if ($organization) {
name = $organization.name;
logoUrl = $organization.logoUrl || "";
language = $organization.language || "en";
}
}
});
async function handleSubmit() {
const orgId = $user?.organizationId;
if (!orgId) {
error = "User organization ID is missing";
return;
}
loading = true;
success = false;
error = "";
try {
let finalLogoUrl = logoUrl;
if (logoFile) {
const formData = new FormData();
formData.append('file', logoFile);
const assetResponse = await apiFetch<any>(`/assets/organization/${orgId}/logo`, {
method: 'POST',
body: formData,
});
// Update local logoUrl with the actual URL from the backend
finalLogoUrl = assetResponse.logoUrl;
logoUrl = finalLogoUrl;
}
const updated = await apiFetch<any>(`/organizations/${orgId}`, {
method: 'PATCH',
body: JSON.stringify({ name, logoUrl: finalLogoUrl || "", language }),
});
organization.set(updated);
success = true;
logoFile = null;
} catch (e: any) {
error = e.message || "Failed to update organization";
} finally {
loading = false;
}
}
function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
if (file.size > MAX_FILE_SIZE) {
error = "File size exceeds 10MB limit";
target.value = "";
return;
}
logoFile = file;
// If a file is selected, clear the logoUrl to avoid ambiguity
logoUrl = "";
error = "";
}
}
function handleUrlChange() {
// If the user starts typing a URL, clear the file selection
if (logoFile) {
logoFile = null;
if (logoFileInput) {
logoFileInput.value = "";
}
}
}
// Reactive preview URL
$: previewUrl = logoFile ? URL.createObjectURL(logoFile) : resolveAssetUrl(logoUrl);
function handleRemoveLogo() {
logoUrl = "";
logoFile = null;
}
</script>
<div class="max-w-2xl mx-auto py-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6">{$t('organization.settings')}</h2>
<div class="bg-white shadow rounded-lg p-6">
<form on:submit|preventDefault={handleSubmit} class="space-y-6">
<div>
<label for="orgName" class="block text-sm font-medium text-gray-700">{$t('organization.name')}</label>
<input
id="orgName"
type="text"
bind:value={name}
required
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Enter organization name"
/>
</div>
<div>
<label for="orgLanguage" class="block text-sm font-medium text-gray-700">{$t('common.language')}</label>
<select
id="orgLanguage"
bind:value={language}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="en">English</option>
<option value="es-ES">Español (Ibérico)</option>
</select>
</div>
<div>
<label for="logoFile" class="block text-sm font-medium text-gray-700">{$t('organization.logo')}</label>
<div class="mt-1 flex items-center space-x-4">
<input
id="logoFile"
type="file"
accept="image/*"
bind:this={logoFileInput}
on:change={handleFileChange}
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100"
/>
</div>
<p class="mt-2 text-sm text-gray-500">Upload a logo for your organization (max 10MB).</p>
</div>
<div>
<label for="logoUrl" class="block text-sm font-medium text-gray-700">Logo URL (Optional)</label>
<input
id="logoUrl"
type="text"
bind:value={logoUrl}
on:input={handleUrlChange}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="https://example.com/logo.png"
/>
<p class="mt-2 text-sm text-gray-500">Or provide a URL for your organization's logo.</p>
</div>
{#if previewUrl}
<div class="mt-4">
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium text-gray-700">Logo Preview:</p>
<button
type="button"
on:click={handleRemoveLogo}
class="text-sm text-red-600 hover:text-red-800"
>
Remove Logo
</button>
</div>
<div class="w-20 h-20 border rounded-lg flex items-center justify-center overflow-hidden bg-gray-50">
<img src={previewUrl} alt="Logo preview" class="max-w-full max-h-full object-contain" />
</div>
</div>
{/if}
{#if error}
<div class="text-red-600 text-sm mt-2">{error}</div>
{/if}
{#if success}
<div class="text-green-600 text-sm mt-2">{$t('organization.updateSuccess')}</div>
{/if}
<div class="flex justify-end">
<button
type="submit"
disabled={loading}
class="bg-indigo-600 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{loading ? $t('common.loading') : $t('common.save')}
</button>
</div>
</form>
</div>
</div>

View File

@ -1,187 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { token } from '$lib/stores/auth';
import { apiFetch } from '$lib/api/client';
let utilizationStats = null;
let employeeStats = null;
let loading = true;
let error = null;
async function fetchStats() {
try {
const [utilRes, empRes] = await Promise.all([
apiFetch<any>('/reports/utilization'),
apiFetch<any>('/reports/employees')
]);
utilizationStats = utilRes;
employeeStats = empRes;
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
async function downloadReport() {
try {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
const res = await fetch(`${API_BASE_URL}/reports/export`, {
headers: {
'Authorization': `Bearer ${$token}`
}
});
if (!res.ok) throw new Error($t('reports.exportFailed'));
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'reservations-report.xlsx';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
} catch (e) {
alert($t('reports.downloadError', { values: { error: e.message } }));
}
}
onMount(fetchStats);
</script>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-800">{$t('reports.title')}</h1>
<button
on:click={downloadReport}
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center space-x-2"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
<span>{$t('reports.downloadData')}</span>
</button>
</div>
{#if loading}
<div class="flex justify-center items-center py-20">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
{:else if error}
<div class="bg-red-50 border-l-4 border-red-400 p-4">
<div class="flex">
<div class="ml-3">
<p class="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
{:else}
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<h3 class="text-sm font-medium text-gray-500 uppercase tracking-wider">{$t('reports.overallUtilization')}</h3>
<p class="mt-2 text-3xl font-bold text-indigo-600">{utilizationStats.summary.overallUtilization.toFixed(1)}%</p>
<div class="mt-4 w-full bg-gray-200 rounded-full h-2">
<div class="bg-indigo-600 h-2 rounded-full" style="width: {utilizationStats.summary.overallUtilization}%"></div>
</div>
</div>
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<h3 class="text-sm font-medium text-gray-500 uppercase tracking-wider">{$t('reports.totalSpaces')}</h3>
<p class="mt-2 text-3xl font-bold text-gray-900">{utilizationStats.summary.totalSpaces}</p>
<p class="text-sm text-gray-500 mt-1">{$t('reports.occupiedNow', { values: { count: utilizationStats.summary.occupiedSpaces } })}</p>
</div>
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<h3 class="text-sm font-medium text-gray-500 uppercase tracking-wider">{$t('reports.totalEmployees')}</h3>
<p class="mt-2 text-3xl font-bold text-gray-900">{employeeStats.totalEmployees}</p>
<p class="text-sm text-gray-500 mt-1">{$t('reports.acrossRoles')}</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Building Utilization -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100">
<h3 class="font-bold text-gray-800">{$t('reports.utilizationByBuilding')}</h3>
</div>
<div class="p-6">
<div class="space-y-4">
{#each utilizationStats.buildingStats as building}
<div>
<div class="flex justify-between text-sm mb-1">
<span class="font-medium text-gray-700">{building.name}</span>
<span class="text-gray-500">{building.occupiedSpaces} / {building.totalSpaces} ({building.utilization.toFixed(1)}%)</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-2">
<div class="bg-indigo-500 h-2 rounded-full" style="width: {building.utilization}%"></div>
</div>
</div>
{/each}
</div>
</div>
</div>
<!-- Space Type Distribution -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100">
<h3 class="font-bold text-gray-800">{$t('reports.utilizationBySpaceType')}</h3>
</div>
<div class="p-6">
<div class="space-y-4">
{#each utilizationStats.typeStats as typeStat}
<div>
<div class="flex justify-between text-sm mb-1">
<span class="font-medium text-gray-700">{$t(`booking.spaceType.${typeStat.type}`)}</span>
<span class="text-gray-500">{typeStat.occupied} / {typeStat.total} ({typeStat.utilization.toFixed(1)}%)</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-2">
<div class="bg-emerald-500 h-2 rounded-full" style="width: {typeStat.utilization}%"></div>
</div>
</div>
{/each}
</div>
</div>
</div>
<!-- Role Distribution -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100">
<h3 class="font-bold text-gray-800">{$t('reports.employeeRoleDistribution')}</h3>
</div>
<div class="p-6">
<div class="grid grid-cols-2 gap-4">
{#each Object.entries(employeeStats.roleDistribution) as [role, count]}
<div class="bg-gray-50 p-4 rounded-lg">
<p class="text-sm text-gray-500 uppercase tracking-wide">{role}</p>
<p class="text-2xl font-bold text-gray-900">{count}</p>
</div>
{/each}
</div>
</div>
</div>
<!-- Top Bookers -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100">
<h3 class="font-bold text-gray-800">{$t('reports.topBookers')}</h3>
</div>
<div class="divide-y divide-gray-100">
{#each employeeStats.topBookers as booker}
<div class="px-6 py-3 flex justify-between items-center hover:bg-gray-50 transition-colors">
<span class="text-sm font-medium text-gray-700">{booker.email}</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
{$t('reports.bookingsCount', { values: { count: booker.reservationsCount } })}
</span>
</div>
{/each}
{#if employeeStats.topBookers.length === 0}
<div class="px-6 py-8 text-center text-gray-500 italic">{$t('reports.noBookingsFound')}</div>
{/if}
</div>
</div>
</div>
{/if}
</div>

View File

@ -1,233 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiFetch } from '$lib/api/client';
import { token } from '$lib/stores/auth';
import { t, locale } from 'svelte-i18n';
import { format } from 'date-fns';
import { enUS, es } from 'date-fns/locale';
$: dateLocale = $locale === 'es-ES' ? es : enUS;
$: dateFormat = $locale === 'es-ES' ? 'dd/MM/yyyy' : 'PP';
let users = [];
let loading = true;
let error = null;
let inviteToken = '';
let showInviteModal = false;
let inviteRole = 'EMPLOYEE';
let copied = false;
let showPasswordModal = false;
let selectedUser = null;
let newPassword = '';
async function fetchUsers() {
try {
users = await apiFetch<any[]>('/users');
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
async function updateUserRole(userId: string, newRole: string) {
try {
await apiFetch(`/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify({ role: newRole })
});
await fetchUsers();
} catch (e) {
alert($t('users.updateRoleError', { values: { error: e.message } }));
}
}
async function openPasswordModal(user) {
selectedUser = user;
newPassword = '';
showPasswordModal = true;
}
async function updatePassword() {
try {
await apiFetch(`/users/${selectedUser.id}`, {
method: 'PATCH',
body: JSON.stringify({ password: newPassword })
});
showPasswordModal = false;
alert($t('users.password.success'));
} catch (e) {
alert($t('users.password.error', { values: { error: e.message } }));
}
}
async function generateInvite() {
try {
const res = await apiFetch<any>('/users/invite', {
method: 'POST',
body: JSON.stringify({ role: inviteRole })
});
const baseUrl = window.location.origin;
inviteToken = `${baseUrl}/register?token=${res.token}`;
showInviteModal = true;
} catch (e) {
alert($t('users.invite.error', { values: { error: e.message } }));
}
}
function copyInvite() {
navigator.clipboard.writeText(inviteToken);
copied = true;
setTimeout(() => copied = false, 2000);
}
onMount(fetchUsers);
</script>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-800">{$t('users.title')}</h1>
<div class="flex space-x-2">
<select bind:value={inviteRole} class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="EMPLOYEE">{$t('users.roles.EMPLOYEE')}</option>
<option value="SUPERVISOR">{$t('users.roles.SUPERVISOR')}</option>
<option value="ADMIN">{$t('users.roles.ADMIN')}</option>
</select>
<button
on:click={generateInvite}
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg font-medium transition-colors"
>
{$t('users.invite.createLink')}
</button>
</div>
</div>
{#if loading}
<div class="flex flex-col items-center justify-center py-12 space-y-4">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
<p class="text-gray-500">{$t('users.loading')}</p>
</div>
{:else if error}
<div class="bg-red-50 text-red-700 p-4 rounded-lg border border-red-200">
{error}
</div>
{:else}
<div class="bg-white shadow-sm border border-gray-100 rounded-xl overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t('users.table.user')}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t('users.table.role')}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t('users.table.joined')}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{$t('users.table.actions')}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each users as user}
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{user.email}</div>
<div class="text-xs text-gray-500">{$t('users.table.id')}: {user.id.substring(0, 8)}...</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<select
value={user.role}
on:change={(e) => updateUserRole(user.id, e.target.value)}
class="text-sm rounded-md border-gray-200 focus:ring-indigo-500 focus:border-indigo-500 py-1"
>
<option value="EMPLOYEE">{$t('users.roles.EMPLOYEE')}</option>
<option value="SUPERVISOR">{$t('users.roles.SUPERVISOR')}</option>
<option value="ADMIN">{$t('users.roles.ADMIN')}</option>
</select>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{format(new Date(user.createdAt), dateFormat, { locale: dateLocale })}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
on:click={() => openPasswordModal(user)}
class="text-indigo-600 hover:text-indigo-900 bg-indigo-50 px-3 py-1 rounded-md"
>
{$t('users.password.change')}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Invite Modal -->
{#if showInviteModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-xl p-6 max-w-lg w-full shadow-xl">
<h3 class="text-lg font-bold text-gray-900 mb-4">{$t('users.invite.generated')}</h3>
<p class="text-sm text-gray-500 mb-4">{@html $t('users.invite.description', { values: { role: $t(`users.roles.${inviteRole}`) } })}</p>
<div class="flex items-center space-x-2 bg-gray-50 p-3 rounded-lg border border-gray-200 mb-6">
<input type="text" readonly value={inviteToken} class="bg-transparent border-none focus:ring-0 text-sm flex-1 truncate" />
<button
on:click={copyInvite}
class="bg-white border border-gray-300 px-3 py-1.5 rounded-md text-sm font-medium hover:bg-gray-50 flex items-center space-x-1"
>
{#if copied}
<span class="text-green-600">{$t('users.invite.copied')}</span>
{:else}
<span>{$t('users.invite.copy')}</span>
{/if}
</button>
</div>
<div class="flex justify-end">
<button
on:click={() => showInviteModal = false}
class="bg-gray-900 text-white px-6 py-2 rounded-lg font-medium"
>
{$t('users.invite.done')}
</button>
</div>
</div>
</div>
{/if}
<!-- Password Modal -->
{#if showPasswordModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-xl p-6 max-w-md w-full shadow-xl">
<h3 class="text-lg font-bold text-gray-900 mb-2">{$t('users.password.title')}</h3>
<p class="text-sm text-gray-500 mb-4">{@html $t('users.password.description', { values: { email: selectedUser?.email } })}</p>
<div class="space-y-4 mb-6">
<div>
<label for="new-password" class="block text-sm font-medium text-gray-700 mb-1">{$t('users.password.new')}</label>
<input
type="password"
id="new-password"
bind:value={newPassword}
placeholder={$t('users.password.placeholder')}
class="w-full rounded-lg border-gray-300 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div>
<div class="flex justify-end space-x-3">
<button
on:click={() => showPasswordModal = false}
class="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 rounded-lg"
>
{$t('common.cancel')}
</button>
<button
on:click={updatePassword}
disabled={newPassword.length < 6}
class="bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white px-6 py-2 rounded-lg font-medium"
>
{$t('users.password.save')}
</button>
</div>
</div>
</div>
{/if}

View File

@ -1,129 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { token, user } from "$lib/stores/auth";
import { goto } from "$app/navigation";
import { apiFetch } from "$lib/api/client";
import { Building2, ArrowRight, Plus, Edit2 } from "lucide-svelte";
import BuildingEditorModal from "$lib/components/admin/BuildingEditorModal.svelte";
import { resolveAssetUrl } from "$lib/utils/url";
import { t } from "svelte-i18n";
let buildings = [];
let loading = true;
let showModal = false;
let editingBuilding = null;
async function fetchBuildings() {
loading = true;
try {
buildings = await apiFetch("/spaces/org-hierarchy");
} catch (e) {
console.error(e);
if (e instanceof Error && e.message.includes('401')) {
goto("/login");
}
} finally {
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>
<div class="p-8 max-w-6xl mx-auto">
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">{$t('dashboard.title')}</h1>
<p class="text-gray-500 mt-1">{$t('dashboard.welcome')}, {$user?.email}!</p>
</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} />
{$t('dashboard.addBuilding')}
</button>
{/if}
</div>
{#if loading}
<div class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each buildings as building}
<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={resolveAssetUrl(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="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} />
</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={$t('common.edit')}
>
<Edit2 size={18} />
</button>
{/if}
</div>
<h2 class="text-xl font-bold text-gray-900 mb-2">{building.name}</h2>
{#if building.description}
<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">
{$t('dashboard.floors', { values: { count: building.floors?.length || 0 } })}
</p>
<a
href="/dashboard/building/{building.id}"
class="inline-flex items-center justify-center w-full px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors gap-2"
>
{$t('dashboard.viewBuilding')}
<ArrowRight size={18} />
</a>
</div>
</div>
{/each}
{#if buildings.length === 0}
<div class="col-span-full bg-gray-50 border-2 border-dashed border-gray-300 rounded-xl p-12 text-center">
<Building2 class="mx-auto text-gray-400 mb-4" size={48} />
<h3 class="text-lg font-medium text-gray-900">{$t('dashboard.noBuildings')}</h3>
<p class="text-gray-500 mt-1">{$t('dashboard.noBuildingsDesc')}</p>
</div>
{/if}
</div>
{/if}
</div>
<BuildingEditorModal
bind:show={showModal}
building={editingBuilding}
on:save={fetchBuildings}
/>

View File

@ -1,186 +0,0 @@
<script lang="ts">
import { page } from "$app/stores";
import { onMount } from "svelte";
import { token, user } from "$lib/stores/auth";
import { goto } from "$app/navigation";
import { apiFetch } from "$lib/api/client";
import FloorplanViewer from '$lib/components/FloorplanViewer.svelte';
import { Building2, Layers, Map as MapIcon, ChevronLeft, Settings, Calendar } from "lucide-svelte";
import { t, locale } from "svelte-i18n";
import { format } from "date-fns";
import { es, enUS } from "date-fns/locale";
let building: any = null;
let currentFloor: any = null;
let loading = true;
let loadingFloor = false;
let selectedFloorId = "";
let selectedDateString = new Date().toISOString().split('T')[0];
let selectedDate = new Date();
$: {
selectedDate = new Date(selectedDateString);
// Add time component to the selected date if it's today, otherwise use start of day
if (selectedDateString === new Date().toISOString().split('T')[0]) {
selectedDate = new Date();
} else {
selectedDate.setHours(7, 0, 0, 0); // Default to 7 AM for other days
}
}
$: dateLocale = $locale?.startsWith('es') ? es : enUS;
$: dateFormat = $locale === 'es-ES' ? 'dd/MM/yyyy' : 'EEEE, MMMM d';
onMount(async () => {
const buildingId = $page.params.id;
if (!$token) {
goto(`/login?redirect=/dashboard/building/${buildingId}`);
return;
}
try {
building = await apiFetch(`/spaces/building/${buildingId}`);
if (building.floors && building.floors.length > 0) {
selectedFloorId = building.floors[0].id;
await loadFloor(selectedFloorId);
}
} catch (e: any) {
console.error(e);
if (e.message?.includes('401')) {
goto("/login");
}
} finally {
loading = false;
}
});
async function loadFloor(floorId: string) {
loadingFloor = true;
try {
currentFloor = await apiFetch(`/spaces/floor/${floorId}?date=${selectedDate.toISOString()}`);
} catch (e) {
console.error(e);
} finally {
loadingFloor = false;
}
}
$: if (selectedFloorId && building && selectedDate) {
// Re-fetch if floor or date changes
loadFloor(selectedFloorId);
}
</script>
<div class="p-8 max-w-7xl mx-auto">
<div class="mb-6">
<a href="/dashboard" class="inline-flex items-center text-indigo-600 hover:text-indigo-700 font-medium gap-1">
<ChevronLeft size={20} />
{$t('building.back')}
</a>
</div>
{#if loading}
<div class="flex flex-col items-center justify-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mb-4"></div>
<p class="text-gray-500">{$t('building.loading')}</p>
</div>
{:else if building}
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center mb-8 gap-4">
<div class="flex items-center gap-4">
<div class="w-14 h-14 bg-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-100">
<Building2 class="text-white" size={30} />
</div>
<div>
<h1 class="text-3xl font-bold text-gray-900">{building.name}</h1>
<div class="flex items-center gap-4 mt-1 text-gray-500">
<span class="flex items-center gap-1.5 text-sm">
<Layers size={16} />
{$t('dashboard.floors', { values: { count: building.floors?.length || 0 } })}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-3 w-full lg:w-auto">
<div class="relative flex-1 lg:flex-none min-w-[200px]">
<span class="absolute -top-2 left-3 px-1 bg-gray-50 text-[10px] font-bold text-indigo-600 uppercase tracking-wider">{$t('building.selectDate')}</span>
<input
id="date-select"
type="date"
bind:value={selectedDateString}
min={new Date().toISOString().split('T')[0]}
class="absolute inset-0 opacity-0 cursor-pointer z-10 w-full h-full"
on:click={(e) => e.currentTarget.showPicker()}
/>
<div class="w-full pl-3 pr-4 py-3 bg-white border border-gray-300 rounded-xl font-medium text-gray-700 shadow-sm flex items-center justify-between">
<span class="capitalize">{format(selectedDate, dateFormat, { locale: dateLocale })}</span>
<Calendar size={18} class="text-indigo-600" />
</div>
</div>
<div class="relative flex-1 lg:flex-none min-w-[200px]">
<label for="floor-select" class="absolute -top-2 left-3 px-1 bg-gray-50 text-[10px] font-bold text-indigo-600 uppercase tracking-wider">{$t('building.selectFloor')}</label>
<select
id="floor-select"
bind:value={selectedFloorId}
class="w-full pl-3 pr-10 py-3 bg-white border border-gray-300 rounded-xl appearance-none focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-medium text-gray-700 shadow-sm"
>
{#each building.floors as floor}
<option value={floor.id}>{$t('building.floor', { values: { number: floor.number } })}</option>
{/each}
</select>
<div class="absolute inset-y-0 right-3 flex items-center pointer-events-none text-gray-400">
<Layers size={20} />
</div>
</div>
{#if $user?.role === 'ADMIN' && selectedFloorId}
<a
href="/admin/floorplan/{selectedFloorId}"
class="p-3 bg-white border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 transition-colors shadow-sm"
title={$t('building.editFloorplan')}
>
<Settings size={20} />
</a>
{/if}
</div>
</div>
<div class="bg-gray-100 rounded-3xl p-8 min-h-[700px] flex items-center justify-center border border-gray-200 shadow-inner relative overflow-hidden">
{#if loadingFloor}
<div class="flex flex-col items-center">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-600 mb-4"></div>
<p class="text-gray-500 font-medium">{$t('building.switching')}</p>
</div>
{:else if currentFloor}
<div class="flex flex-col items-center">
<FloorplanViewer
floorId={currentFloor.id}
spaces={currentFloor.spaces}
planImageUrl={currentFloor.planImageUrl}
selectedDate={selectedDate}
/>
{#if currentFloor.planImageUrl}
<p class="mt-6 text-sm text-gray-500 flex items-center gap-2">
<MapIcon size={16} />
{$t('building.interactiveView')}
</p>
{:else}
<p class="mt-6 text-sm text-gray-400 flex items-center gap-2 italic">
<MapIcon size={16} />
{$t('building.noFloorplan')}
</p>
{/if}
</div>
{:else}
<p class="text-gray-400">{$t('building.selectToSee')}</p>
{/if}
</div>
{:else}
<div class="text-center py-20">
<h2 class="text-2xl font-bold text-gray-900">{$t('building.notFound')}</h2>
<p class="text-gray-500 mt-2">{$t('building.notFoundDesc')}</p>
<a href="/dashboard" class="mt-8 inline-block text-indigo-600 font-medium">{$t('building.return')}</a>
</div>
{/if}
</div>

View File

@ -1,112 +0,0 @@
<script lang="ts">
import { page } from "$app/stores";
import { onMount } from "svelte";
import { token } from "$lib/stores/auth";
import { goto } from "$app/navigation";
import SpaceCard from '$lib/components/SpaceCard.svelte';
import { apiFetch } from "$lib/api/client";
import FloorplanViewer from '$lib/components/FloorplanViewer.svelte';
import { Calendar, ChevronLeft, Layers } from "lucide-svelte";
let floor: any = null;
let loading = true;
let selectedDateString = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD in local time
let selectedDate = new Date();
$: {
selectedDate = new Date(selectedDateString + 'T00:00:00'); // Initialize to start of day in local time
if (selectedDateString === new Date().toLocaleDateString('en-CA')) {
selectedDate = new Date();
} else {
selectedDate.setHours(7, 0, 0, 0);
}
}
let floorIdFromUrl = $page.params.id;
$: floorIdFromUrl = $page.params.id;
onMount(async () => {
if (!$token) {
goto(`/login?redirect=/dashboard/floor/${floorIdFromUrl}`);
return;
}
await loadFloor(floorIdFromUrl, selectedDate);
loading = false;
});
async function loadFloor(id: string, date: Date) {
try {
floor = await apiFetch(`/spaces/floor/${id}?date=${date.toISOString()}`);
} catch (e: any) {
console.error(e);
if (e.message?.includes('401')) {
goto("/login");
}
}
}
$: if (floorIdFromUrl && selectedDate) {
loadFloor(floorIdFromUrl, selectedDate);
}
</script>
<div class="p-8">
<a href="/dashboard" class="text-blue-600 hover:underline mb-4 inline-block">&larr; Back to Dashboard</a>
{#if loading}
<p>Loading floor layout...</p>
{:else if floor}
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">Floor {floor.number} - {floor.building?.name}</h1>
<p class="text-gray-500 mt-1">{floor.building?.organization?.name}</p>
</div>
<div class="flex items-center gap-3">
<div class="relative min-w-[200px]">
<span class="absolute -top-2 left-3 px-1 bg-white text-[10px] font-bold text-indigo-600 uppercase tracking-wider">Select Date</span>
<input
id="date-select"
type="date"
bind:value={selectedDateString}
min={new Date().toLocaleDateString('en-CA')}
class="absolute inset-0 opacity-0 cursor-pointer z-10 w-full h-full"
on:click={(e) => e.currentTarget.showPicker()}
/>
<div class="w-full pl-3 pr-4 py-3 bg-white border border-gray-300 rounded-xl font-medium text-gray-700 shadow-sm flex items-center justify-between">
<span>{selectedDate.toLocaleDateString('en-CA', { weekday: 'long', month: 'long', day: 'numeric' })}</span>
<Calendar size={18} class="text-indigo-600" />
</div>
</div>
</div>
</div>
<div class="mb-12">
<h2 class="text-lg font-semibold mb-4 text-gray-700 uppercase tracking-wide">Interactive Floorplan</h2>
<FloorplanViewer
floorId={floor.id}
spaces={floor.spaces}
planImageUrl={floor.planImageUrl}
selectedDate={selectedDate}
/>
</div>
<h2 class="text-lg font-semibold mb-4 text-gray-700 uppercase tracking-wide">
{floor.planImageUrl ? 'All Spaces' : 'Spaces'}
</h2>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each floor.spaces as space}
<SpaceCard
spaceId={space.id}
name={space.name}
type={space.type}
floorId={floor.id}
initialStatus={space.reservations && space.reservations.length > 0}
selectedDate={selectedDate}
/>
{/each}
</div>
{:else}
<p>Floor not found.</p>
{/if}
</div>

View File

@ -1,8 +0,0 @@
import { error } from '@sveltejs/kit';
import { apiFetch } from '$lib/api/client';
export const load = async () => {
return {};
};
export const ssr = false;

View File

@ -1,340 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { token } from "$lib/stores/auth";
import { apiFetch } from "$lib/api/client";
import { Calendar, Clock, MapPin, Trash2, Edit2, ChevronRight, X, ChevronDown, ChevronUp } from "lucide-svelte";
import { format, isPast } from "date-fns";
import { es, enUS } from 'date-fns/locale';
import { t, locale } from "svelte-i18n";
let reservations = [];
let loading = true;
let error = null;
let editingReservation = null;
let editStartTimeDate = "";
let editStartTimeTime = "";
let editEndTimeDate = "";
let editEndTimeTime = "";
let saving = false;
let showPast = false;
$: dateLocale = $locale === 'es-ES' ? es : enUS;
$: dateFormat = $locale === 'es-ES' ? 'dd/MM/yyyy' : 'PPP';
$: upcomingReservations = reservations.filter(r => !isPast(new Date(r.endTime)));
$: pastReservations = reservations.filter(r => isPast(new Date(r.endTime)));
onMount(async () => {
await fetchReservations();
});
async function fetchReservations() {
loading = true;
try {
reservations = await apiFetch("/reservations/my");
} catch (e) {
error = $t('myBookings.loadFailed');
console.error(e);
} finally {
loading = false;
}
}
async function deleteReservation(id) {
if (!confirm($t('myBookings.cancelConfirm'))) return;
try {
await apiFetch(`/reservations/${id}`, { method: "DELETE" });
reservations = reservations.filter(r => r.id !== id);
} catch (e) {
alert($t('myBookings.cancelFailed'));
}
}
function openEditModal(res) {
editingReservation = res;
// Separate date and time for inputs
const start = new Date(res.startTime);
const end = new Date(res.endTime);
editStartTimeDate = format(start, "yyyy-MM-dd");
editStartTimeTime = format(start, "HH:mm");
editEndTimeDate = format(end, "yyyy-MM-dd");
editEndTimeTime = format(end, "HH:mm");
}
function closeEditModal() {
editingReservation = null;
}
async function handleUpdate() {
saving = true;
try {
const start = new Date(`${editStartTimeDate}T${editStartTimeTime}`);
const end = new Date(`${editEndTimeDate}T${editEndTimeTime}`);
const now = new Date();
if (editingReservation.space.type === 'DESK') {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (start < today) {
alert($t('booking.errorPast'));
saving = false;
return;
}
} else if (start < now) {
alert($t('booking.errorPast'));
saving = false;
return;
}
await apiFetch(`/reservations/${editingReservation.id}`, {
method: "PATCH",
body: JSON.stringify({
startTime: start.toISOString(),
endTime: end.toISOString(),
})
});
await fetchReservations();
closeEditModal();
} catch (e: any) {
alert(e.message || $t('myBookings.updateFailed'));
} finally {
saving = false;
}
}
function getSpaceTypeLabel(type) {
return $t(`booking.spaceType.${type}`);
}
</script>
<div class="max-w-4xl mx-auto">
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">{$t('myBookings.title')}</h1>
<p class="text-gray-500 mt-1">{$t('myBookings.description')}</p>
</div>
</div>
{#if loading && !reservations.length}
<div class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
{:else if error}
<div class="bg-red-50 text-red-700 p-4 rounded-lg border border-red-200">
{error}
</div>
{:else if reservations.length === 0}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
<Calendar class="mx-auto text-gray-300 mb-4" size={48} />
<h3 class="text-lg font-medium text-gray-900">{$t('myBookings.noBookings')}</h3>
<p class="text-gray-500 mt-1">{$t('myBookings.noBookingsDesc')}</p>
<a href="/dashboard" class="mt-6 inline-flex items-center text-indigo-600 font-medium hover:underline">
{$t('myBookings.goToDashboard')} <ChevronRight size={16} />
</a>
</div>
{:else}
<div class="space-y-8">
{#if upcomingReservations.length > 0}
<section>
<h2 class="text-xl font-semibold text-gray-900 mb-4">{$t('myBookings.upcoming')}</h2>
<div class="space-y-4">
{#each upcomingReservations as res}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:border-indigo-300 transition-colors">
<div class="p-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex items-start gap-4">
<div class="w-12 h-12 {res.space.type === 'DESK' ? 'bg-blue-50 text-blue-600' : 'bg-purple-50 text-purple-600'} rounded-lg flex items-center justify-center flex-shrink-0">
{#if res.space.type === 'DESK'}
<MapPin size={24} />
{:else}
<Clock size={24} />
{/if}
</div>
<div>
<h3 class="text-lg font-bold text-gray-900">{res.space.name}</h3>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1 text-sm text-gray-500">
<span class="flex items-center gap-1">
<MapPin size={14} />
{res.space.floor.building.name}, {$t('myBookings.floor', { values: { number: res.space.floor.number } })}
</span>
<span class="flex items-center gap-1">
<Calendar size={14} />
{format(new Date(res.startTime), dateFormat, { locale: dateLocale })}
</span>
<span class="flex items-center gap-1">
<Clock size={14} />
{format(new Date(res.startTime), "HH:mm")} - {format(new Date(res.endTime), "HH:mm")}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-2 border-t md:border-t-0 pt-4 md:pt-0">
<button
on:click={() => openEditModal(res)}
class="p-2 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
title={$t('common.edit')}
>
<Edit2 size={20} />
</button>
<button
on:click={() => deleteReservation(res.id)}
class="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title={$t('common.delete')}
>
<Trash2 size={20} />
</button>
</div>
</div>
</div>
{/each}
</div>
</section>
{/if}
{#if pastReservations.length > 0}
<section>
<button
on:click={() => showPast = !showPast}
class="flex items-center justify-between w-full text-left mb-4 group"
>
<h2 class="text-xl font-semibold text-gray-900">
{$t('myBookings.past')}
<span class="text-sm font-normal text-gray-500 ml-2">({pastReservations.length})</span>
</h2>
<div class="flex items-center text-indigo-600 text-sm font-medium">
{showPast ? $t('myBookings.hidePast') : $t('myBookings.showPast')}
{#if showPast}
<ChevronUp size={20} class="ml-1" />
{:else}
<ChevronDown size={20} class="ml-1" />
{/if}
</div>
</button>
{#if showPast}
<div class="space-y-4">
{#each pastReservations as res}
<div class="bg-gray-50 rounded-xl border border-gray-200 overflow-hidden opacity-75">
<div class="p-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex items-start gap-4">
<div class="w-12 h-12 {res.space.type === 'DESK' ? 'bg-gray-200 text-gray-500' : 'bg-gray-200 text-gray-500'} rounded-lg flex items-center justify-center flex-shrink-0">
{#if res.space.type === 'DESK'}
<MapPin size={24} />
{:else}
<Clock size={24} />
{/if}
</div>
<div>
<h3 class="text-lg font-bold text-gray-500">{res.space.name}</h3>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1 text-sm text-gray-400">
<span class="flex items-center gap-1">
<MapPin size={14} />
{res.space.floor.building.name}, {$t('myBookings.floor', { values: { number: res.space.floor.number } })}
</span>
<span class="flex items-center gap-1">
<Calendar size={14} />
{format(new Date(res.startTime), dateFormat, { locale: dateLocale })}
</span>
<span class="flex items-center gap-1">
<Clock size={14} />
{format(new Date(res.startTime), "HH:mm")} - {format(new Date(res.endTime), "HH:mm")}
</span>
</div>
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</section>
{/if}
</div>
{/if}
</div>
{#if editingReservation}
<div class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-2xl shadow-xl max-w-md w-full overflow-hidden">
<div class="p-6 border-b border-gray-100 flex justify-between items-center">
<h3 class="text-xl font-bold text-gray-900">{$t('myBookings.editBookingTitle')}</h3>
<button on:click={closeEditModal} class="text-gray-400 hover:text-gray-600">
<X size={24} />
</button>
</div>
<div class="p-6 space-y-4">
<div class="bg-gray-50 p-4 rounded-xl mb-4">
<p class="text-sm font-medium text-gray-500 uppercase tracking-wider">{$t('myBookings.space')}</p>
<p class="text-lg font-bold text-gray-900">{editingReservation.space.name}</p>
<div class="flex justify-between items-end">
<p class="text-sm text-gray-500">{editingReservation.space.floor.building.name}, {$t('myBookings.floor', { values: { number: editingReservation.space.floor.number } })}</p>
<p class="text-sm font-bold text-indigo-600">{format(new Date(editingReservation.startTime), dateFormat, { locale: dateLocale })}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t('booking.date')}</label>
<div class="relative">
<input
type="date"
bind:value={editStartTimeDate}
min={new Date().toISOString().split('T')[0]}
class="absolute inset-0 opacity-0 cursor-pointer w-full z-10"
on:click={(e) => e.currentTarget.showPicker()}
/>
<div class="w-full px-4 py-2 bg-gray-50 border border-gray-300 rounded-lg text-gray-700 flex justify-between items-center">
<span>{format(new Date(editStartTimeDate + 'T00:00:00'), dateFormat, { locale: dateLocale })}</span>
<Calendar size={16} class="text-gray-400" />
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{$t('booking.startTime')}</label>
<input
type="time"
bind:value={editStartTimeTime}
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{$t('booking.endTime')}</label>
<input
type="time"
bind:value={editEndTimeTime}
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
/>
</div>
</div>
{#if editingReservation.space.type === 'DESK'}
<p class="text-xs text-amber-600 bg-amber-50 p-2 rounded">
{$t('myBookings.deskNote')}
</p>
{/if}
</div>
<div class="p-6 bg-gray-50 flex gap-3">
<button
on:click={closeEditModal}
class="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-100 transition-colors"
>
{$t('common.cancel')}
</button>
<button
on:click={handleUpdate}
disabled={saving}
class="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50"
>
{saving ? $t('myBookings.saving') : $t('myBookings.saveChanges')}
</button>
</div>
</div>
</div>
{/if}

Some files were not shown because too many files have changed in this diff Show More