diff --git a/apps/concierge/USER_STORIES.md b/apps/concierge/USER_STORIES.md new file mode 100644 index 0000000000..eff2cf0c99 --- /dev/null +++ b/apps/concierge/USER_STORIES.md @@ -0,0 +1,945 @@ +# Concierge - User Stories + +This document describes user stories for the Concierge application, a front-of-house and workplace management console used by reception staff, workplace administrators, facilities teams, catering coordinators, and events coordinators. + +The app covers room, desk, parking, locker, catering, asset, visitor, staff, reporting, content, and building configuration workflows. + +## Personas + +- **Concierge**: Front-of-house staff who manage daily bookings, visitor arrivals, approvals, and on-the-day exceptions. +- **Workplace Administrator**: Admin users who configure building hierarchy, bookable resources, booking rules, app settings, content, and access-controlled management areas. +- **Facilities Manager**: Facilities staff who monitor operational state, respond to issues, manage room configuration, and run occupancy reports. +- **Catering Coordinator**: Catering staff or admins who review orders, manage menus, and keep catering rules current. +- **Events Coordinator**: Staff who create, publish, and maintain events, offers, and promotional content. +- **Reporting User**: Managers who generate, export, and print operational reports for compliance, utilisation, and planning. + +> **Routing note:** The app uses hash routing. All routes below are reached at `/#/`. Appending `?mock=true` (for example `/#/book/lockers/manage?mock=true`) loads the app against mock data with authentication bypassed; this is how the e2e tests drive the app. + +--- + +## Application Shell & Navigation + +### US-001: Land on the default page +**As a** concierge +**I want to** open the app and land on a useful default page +**So that** I can start working without extra navigation + +**Acceptance Criteria:** +- Opening `/#/` redirects through the configured `default_route`. +- The default route is expected to take users to room bookings unless the configured concierge settings override it. +- The top bar (`app-topbar`) and side navigation (`app-sidebar`) are visible once the route loads. +- The route loads against mock data when `?mock=true` is supplied. +- If the target route is unavailable because the feature is disabled, the app redirects to a reachable route instead of leaving the shell blank. + +### US-002: Navigate between feature areas +**As a** concierge +**I want to** use the sidebar to move between bookings, management, reports, and standalone tools +**So that** I can reach any operational area quickly + +**Acceptance Criteria:** +- The sidebar groups links under Bookings, Management, and Reports where child links exist. +- Bookings links include room bookings, desk bookings, parking bookings, locker bookings, asset bookings, catering bookings, visitor bookings, and visitor rules when those features are enabled. +- Management links include zones, rooms, desks, parking, lockers, catering, points, emergency contacts, signage, map features, URL management, email templates, and deals when those features are enabled. +- Standalone links include assets, staff, events, and surveys when enabled. +- The active route is highlighted. +- Links hidden by `app.features` or `app.feature_groups` are not shown to users without access. +- Management items are hidden from non-admin users. +- Custom report links configured in settings appear under Reports without duplicating built-in report IDs. + +### US-003: Access account, notifications, and global state +**As a** concierge +**I want to** see notifications, account controls, loading state, and global app banners +**So that** I can manage my session and understand app-wide status + +**Acceptance Criteria:** +- The top bar shows the configured logo, notification control, and current user avatar. +- The logo links back to the landing route. +- Global banners render above the active route when configured. +- Global loading state is visible while blocking app-level operations are in progress. +- Global chat is shown only when enabled by the PlaceOS service. + +### US-004: Respect authorisation and unavailable routes +**As a** workplace administrator +**I want to** restrict routes to authorised users +**So that** operational and administrative data is not exposed to the wrong audience + +**Acceptance Criteria:** +- Routes that require authentication use the application guard before loading feature modules. +- Unauthorised users are sent to the unauthorised page. +- Unknown routes redirect through the app fallback route. +- Feature links are filtered before users can navigate from the sidebar. + +--- + +## Room Bookings (Day View) + +Route: `/#/book/rooms` - container `[app-new-dayview]` + +### US-010: View room bookings for the day +**As a** concierge +**I want to** see all room bookings for the selected day +**So that** I know which rooms are in use and when + +**Acceptance Criteria:** +- The day view renders the booking list or timeline (`room-bookings`). +- Bookings can be viewed as timeline or list when both views are enabled. +- The selected date controls which bookings are shown. +- Each booking shows enough context to identify the room, organiser, time, and status. +- The view handles an empty day with a clear empty state. +- Cancelled or declined events are visually distinct from active bookings when they are included in the filter. + +### US-011: Approve a pending room booking +**As a** concierge +**I want to** approve a pending room booking request +**So that** the requester gets confirmation of their reservation + +**Acceptance Criteria:** +- Pending room bookings appear in the approvals area or row actions with an Approve action. +- Approving a booking updates its status to Approved. +- The action is disabled or removed after the approval succeeds. +- For recurring bookings, the user can approve a single occurrence or the whole series where the backend supports both scopes. +- A failed approval leaves the booking in its previous state and communicates the failure. + +### US-012: Decline a room booking +**As a** concierge +**I want to** decline a pending room booking +**So that** the room is freed and the requester is informed + +**Acceptance Criteria:** +- Pending room bookings expose a Decline action. +- Declining a booking updates its status to Declined. +- The action is reflected immediately in the approvals panel and booking list. +- For recurring bookings, the user can decline a single occurrence or the whole series where the backend supports both scopes. +- Declined bookings do not continue to block room availability. + +### US-013: Create a room booking +**As a** concierge +**I want to** create a new room booking on behalf of a user +**So that** I can reserve a space for someone at reception + +**Acceptance Criteria:** +- The New Booking control opens the booking modal. +- The form captures organiser, room, date, start time, end time, title, attendees, and any configured extra fields. +- The form supports charge code selection when charge codes are configured. +- Room availability is respected before saving. +- Saving the booking closes the modal and adds the booking to the active day view. +- Validation prevents saving without required booking details. + +### US-014: Filter and export room bookings +**As a** concierge +**I want to** filter room bookings and download attendee data +**So that** I can focus on relevant bookings and share attendance data + +**Acceptance Criteria:** +- Bookings can be filtered by Internal, External, and Cancelled event types. +- Bookings can be filtered by one or more levels. +- Level filters update the URL with `zone_ids` so the filtered view can be shared or reloaded. +- The setup and breakdown display can be toggled when `app.events.allow_setup_breakdown` is enabled. +- An attendee list can be downloaded when data is available. + +### US-015: Preserve route-driven room context +**As a** concierge +**I want to** reload a filtered room booking URL +**So that** I can return to the same operational context + +**Acceptance Criteria:** +- `zone_ids` query parameters restore the selected levels. +- A level from another building switches the active building to the matching parent building. +- Invalid or empty `zone_ids` values are ignored instead of breaking the page. +- Date controls keep the booking list aligned with the selected date. + +--- + +## Desk Bookings + +Routes: `/#/book/desks/events`, `/#/book/desks/map`, and `/#/book/desks/manage` - container `[app-desks]` + +### US-020: View desk bookings +**As a** concierge +**I want to** view desk bookings for the selected date and level +**So that** I can see desk occupancy at a glance + +**Acceptance Criteria:** +- The desk bookings list (`desk-bookings`) renders for the selected date. +- Bookings can be filtered by level, date, and search text. +- The list can be refreshed after booking or approval actions. +- The selected level is reflected in `zone_ids`. +- If all levels are selected, the app shows all bookable desks available to the current building context. +- Empty results show that no matching desk bookings exist. + +### US-021: Book a desk for a user +**As a** concierge +**I want to** create a desk booking on behalf of a user +**So that** I can assign a desk at reception + +**Acceptance Criteria:** +- A New Booking control opens the desk booking modal (`desk-book-modal`). +- The modal allows selecting a user, date, desk, and any required booking details. +- Only bookable desks are selectable for the booking. +- Saving creates the booking and makes it visible in the list. +- Validation prevents saving when the selected desk or user is missing. + +### US-022: Approve or reject desk bookings +**As a** concierge +**I want to** approve or reject desk bookings +**So that** desk requests are confirmed or freed + +**Acceptance Criteria:** +- Desk booking rows expose approval and rejection actions where applicable. +- Approve and Reject can also be triggered by `?approve=` and `?reject=` query parameters. +- Approved bookings show an approved state and no longer appear as pending. +- Rejected bookings show a declined or rejected state and do not block availability. +- "Reject All" rejects all eligible bookings for the current filtered view. +- Bulk rejection refreshes the list after completion. + +### US-023: Manage desks +**As an** administrator +**I want to** add and edit desks, including assignment and bookability +**So that** the desk inventory reflects the real workspace + +**Acceptance Criteria:** +- The manage view (`desks-manage`) lists desks with an add action. +- The desk modal edits `name`, `map_id`, `assigned_user`, `bookable`, features, groups, tags, homebase, and notes where supported. +- New desks are added to local data and require saving before they can be used. +- Desk management can be filtered by level and search text. +- Desk QR codes can be viewed or printed. +- Desk booking rules are configurable from the manage toolbar. + +### US-024: Import and export desks +**As an** administrator +**I want to** bulk import and export desk inventory +**So that** large floor updates do not require one-by-one editing + +**Acceptance Criteria:** +- The manage toolbar exposes a CSV upload action. +- Uploaded CSV rows are parsed into desk records. +- Missing desk IDs are generated before adding rows to local data. +- A template CSV can be downloaded. +- Import errors are reported without losing already loaded desk data. + +### US-025: Use the desk map +**As a** concierge +**I want to** view desks spatially on a map +**So that** I can help users pick a location + +**Acceptance Criteria:** +- `/#/book/desks/map` shows the desk map view. +- Map mode requires a specific level and does not use the "All" level option. +- Desk map data stays aligned with the selected level. +- Selecting a desk from the map can lead to booking or details where supported. + +--- + +## Parking Bookings + +Routes: `/#/book/parking/events/list`, `/#/book/parking/events/map`, `/#/book/parking/events/requests`, `/#/book/parking/events/bookings`, `/#/book/parking/manage/spaces`, `/#/book/parking/manage/users`, `/#/book/parking/manage/fleet`, and `/#/book/parking/manage/map` - container `app-parking` + +### US-030: View parking bookings +**As a** concierge +**I want to** view parking reservations in list, map, or week view +**So that** I can see parking availability and assignments + +**Acceptance Criteria:** +- Parking bookings render as a list (`parking-bookings-list`) or map (`parking-map`). +- The events section defaults to the list view when no child route is supplied. +- The view can be switched between list and map. +- Bookings can be filtered by level, date, period, and search text. +- Map view uses a single selected level. +- The availability counter shows occupied spaces against total spaces when enabled. + +### US-031: Create a parking reservation or request +**As a** concierge +**I want to** create a parking reservation or parking request for a user +**So that** I can assign or request parking at reception + +**Acceptance Criteria:** +- A reservation control opens the parking booking modal when direct reservations are enabled. +- A New Parking Request control opens the request modal (`parking-request-modal`) when request handling is enabled. +- The form captures user, date, vehicle or request details, and selected parking space where applicable. +- Direct reservations are disabled when `disable_reservations` is configured. +- Saving refreshes the active parking list. + +### US-032: Manage parking spaces, users, and fleet +**As an** administrator +**I want to** manage parking spaces, parking users, and fleet vehicles +**So that** the parking inventory and assignments are accurate + +**Acceptance Criteria:** +- Manage tabs cover Spaces, Users, Fleet, and Map. +- The Spaces view requires a selected level before creating a new space. +- The space modal (`parking-space-modal`) edits `identifier`, `map_id`, `assigned_user`, and `bookable`. +- Spaces can be assigned to users. +- Parking users can be created and searched. +- Fleet vehicles can be created and searched. +- Parking booking rules are configurable from the manage toolbar. + +### US-033: Filter parking requests +**As a** concierge +**I want to** filter parking work by request type +**So that** I can focus on the queue I am processing + +**Acceptance Criteria:** +- The events list exposes request filters when request handling is enabled. +- Filters include All, Requests, Pending Manual, Booked, and Waitlisted when waitlist support is enabled. +- Changing the filter updates the list without changing the selected date or level. +- The selected filter does not cause a navigation loop when query parameters update. + +### US-034: Import and export parking spaces +**As an** administrator +**I want to** bulk import and export parking spaces +**So that** parking inventory updates can be managed efficiently + +**Acceptance Criteria:** +- Spaces can be uploaded from CSV when a level is selected. +- A CSV export or template can be downloaded from the Spaces manage view. +- CSV upload is disabled until a level is selected. +- Imported spaces are associated with the active level. +- Invalid upload data is reported without clearing existing spaces. + +### US-035: Preserve parking route state +**As a** concierge +**I want to** reload a parking URL with selected filters +**So that** I can share or return to the same parking view + +**Acceptance Criteria:** +- `zone_ids` restores the selected level or levels. +- `period` restores Day or Week mode where the current view supports period switching. +- Hidden or disabled level selectors do not wipe a user's persisted level choice. +- Manage views snap an empty level selection back to the first available level. + +--- + +## Locker Bookings + +Routes: `/#/book/lockers/events` and `/#/book/lockers/manage` - container `[app-lockers]` + +### US-040: View locker bookings +**As a** concierge +**I want to** view locker bookings for the selected level and date +**So that** I can see locker usage + +**Acceptance Criteria:** +- The locker bookings list (`locker-bookings`) renders. +- Bookings can be filtered by level, date, and search text. +- A New Booking control opens the locker booking modal (`locker-booking-modal`). +- Locker bookings show approved, pending, and declined states where available. +- Empty views show no matching bookings instead of a blank page. + +### US-041: Manage locker banks +**As an** administrator +**I want to** create locker banks and add lockers to them +**So that** the locker inventory is organised by physical location + +**Acceptance Criteria:** +- The manage view (`locker-list`) lists locker banks with an Add Bank action. +- The bank modal (`locker-bank-modal`) requires a `level_id` and edits `name` and `map_id`. +- A bank's row menu allows adding lockers and viewing the bank layout. +- Expanding a bank reveals its child lockers. +- Child lockers can be created, edited, assigned, and marked bookable where supported. + +### US-042: Release lockers +**As an** administrator +**I want to** release all lockers in a view +**So that** I can reset locker assignments in bulk + +**Acceptance Criteria:** +- A Release All control is available in the manage view. +- Releasing requires confirmation. +- Only lockers in the active filtered context are released. +- The view refreshes after release. +- A no-op release communicates that there are no matching lockers to release. + +### US-043: Approve or reject locker bookings +**As a** concierge +**I want to** approve or reject locker bookings +**So that** locker requests match building policy + +**Acceptance Criteria:** +- Eligible locker bookings expose approve and reject actions. +- Approving sets the locker booking to approved. +- Rejecting sets the locker booking to declined. +- Reject All rejects eligible bookings for the selected date. +- Bulk rejection requires confirmation and reports when there are no eligible bookings. + +### US-044: Configure locker booking rules +**As an** administrator +**I want to** manage locker booking rules +**So that** booking windows and access rules match building policy + +**Acceptance Criteria:** +- Booking rules are reachable from the locker toolbar. +- Rules can be scoped to the locker resource type. +- Saving rule changes updates the relevant metadata. + +--- + +## Asset Bookings + +Routes: `/#/book/assets/list/requests`, `/#/book/assets/list/items`, `/#/book/assets/list/purchase-orders`, `/#/book/assets/view/:id`, and `/#/book/assets/manage/*` - container `[asset-manager]` + +### US-050: Review asset requests +**As a** concierge +**I want to** view asset requests, items, and purchase orders +**So that** I can track and fulfil asset needs + +**Acceptance Criteria:** +- The listing has Requests, Items, and Purchase Orders tabs. +- Requests are searchable. +- Request details can be opened for review. +- The active tab controls the search placeholder and available actions. +- Region-enabled deployments can switch the active building from the top bar. + +### US-051: Manage asset inventory +**As an** administrator +**I want to** create and edit assets, groups, and categories +**So that** the asset catalogue stays accurate + +**Acceptance Criteria:** +- Assets can be created or edited via the asset form (`asset-form`). +- Asset fields include `identifier`, `serial_number`, category, group, and location data where configured. +- Asset groups can be created from the Items tab. +- Asset categories can be managed from the asset toolbar. +- Asset booking rules and room availability settings can be configured. +- Asset bulk import is supported from the manage routes. + +### US-052: Browse asset items +**As a** concierge +**I want to** browse asset items in list or grid mode +**So that** I can find the right asset quickly + +**Acceptance Criteria:** +- Items can be viewed as a grid or list. +- Search filters the visible asset items. +- Selecting an asset opens its detail view. +- The selected display mode remains active while navigating within asset listing routes. + +### US-053: Manage purchase orders +**As an** administrator +**I want to** create and review asset purchase orders +**So that** procurement status is visible to operations staff + +**Acceptance Criteria:** +- Purchase orders are listed under `/#/book/assets/list/purchase-orders`. +- A purchase order can be created from `/#/book/assets/manage/purchase-order`. +- Purchase orders are searchable. +- Saved purchase orders return the user to an appropriate asset listing route. + +--- + +## Catering + +Routes: `/#/book/catering/orders`, `/#/book/catering/menu`, and `/#/book/catering/:view` - container `[app-catering]` + +### US-060: View catering orders +**As a** catering coordinator +**I want to** view catering orders and their statuses +**So that** I can coordinate preparation and deliveries + +**Acceptance Criteria:** +- The orders view lists catering orders (`catering-order-list`). +- Orders can be filtered by date, zone, and caterer where supported. +- Order status is visible in the list. +- Order details can be reviewed before fulfilment. +- Empty states explain that there are no matching orders. + +### US-061: Manage catering menu +**As an** administrator +**I want to** edit menu items and pricing +**So that** the catering menu is current + +**Acceptance Criteria:** +- The menu view shows menu items (`catering-menu`). +- Menu items can be added for a building zone. +- Existing menu items can be edited or removed. +- Menus can be imported and exported. +- Catering booking rules can be configured. + +### US-062: Handle catering changes and exceptions +**As a** catering coordinator +**I want to** identify changed, cancelled, or special catering orders +**So that** the catering team can avoid missed or incorrect deliveries + +**Acceptance Criteria:** +- Cancelled or changed orders are distinguishable from active orders. +- Order notes or special instructions are visible in order details where supplied. +- Date and zone filters can isolate the operational window being prepared. +- Exported catering data reflects the active filters where supported. + +--- + +## Visitors + +Route: `/#/book/visitors` - container `[app-new-visitors]` + +### US-070: View visitors for the period +**As a** concierge +**I want to** view expected and present visitors for the day, week, or month +**So that** I can prepare for and manage arrivals + +**Acceptance Criteria:** +- The guest listing (`guest-listings`) renders visitors for the selected period. +- Visitors can be searched by visitor name, email, company, or host details. +- Visitors can be filtered by level where level data is available. +- Rows show visitor, host, time, check-in state, check-out state, notes, parking, pass number, induction, and status fields when enabled. +- Hidden fields configured by settings are not displayed. +- Visitor detail tooltips expose phone, organisation, and reason where provided. + +### US-071: Invite a visitor +**As a** concierge +**I want to** invite a visitor +**So that** they are registered ahead of their arrival + +**Acceptance Criteria:** +- An Invite Visitor control opens the invite visitor modal. +- The form captures visitor details, host, visit time, reason, and any configured compliance fields. +- Parking details can be captured when visitor parking is enabled. +- A completed invite shows a success confirmation. +- Invalid or incomplete invites show validation errors and do not create a booking. + +### US-072: Approve or decline a visitor +**As a** concierge +**I want to** approve or decline a visitor booking +**So that** only authorised visitors are expected + +**Acceptance Criteria:** +- Each eligible visitor row exposes Approve and Decline actions. +- The visitor's status updates to Approved or Declined. +- Declined visitors cannot be checked in unless their status changes. +- Approval and decline actions report failures without losing the original status. + +### US-073: Check a visitor in and out +**As a** concierge +**I want to** check visitors in on arrival and out on departure +**So that** I have an accurate record of who is on site + +**Acceptance Criteria:** +- Check In is available for visitors who are not yet checked in. +- Check Out is available for checked-in visitors. +- All visitors for an event can be checked in or out together. +- A visitor's status indicator reflects not checked in, checked in, or checked out state. +- Check-in time and check-out time are shown using the configured time format and timezone. + +### US-074: Manage visitor details +**As a** concierge +**I want to** edit visitor notes, email visitors, print passes, and download the list +**So that** I can manage visitor information and produce records + +**Acceptance Criteria:** +- Visitor notes can be edited. +- A visitor can be emailed when an email address is available. +- A visitor pass can be printed when the visitor is checked in and pass printing is enabled. +- Visitor pass numbers can be captured when pass numbers are enabled. +- The visitor list can be downloaded. +- Visitor QR codes can be generated or shown where configured. + +### US-075: Configure visitor rules +**As an** administrator +**I want to** configure visitor rules +**So that** visitor invitations follow building policy + +**Acceptance Criteria:** +- Visitor rules are exposed from the sidebar only when the `visitor-rules` feature is enabled. +- The `/#/book/visitors/rules` route must resolve to a rules screen before the feature is enabled in a production configuration. +- Visitor policy settings can be edited by authorised administrators. +- Rule changes affect future invitations without corrupting existing visitor records. + +### US-076: Manage visitor compliance data +**As a** concierge +**I want to** review visitor induction, ID, and international visitor data +**So that** site access requirements are handled before arrival + +**Acceptance Criteria:** +- Induction status is shown when inductions are enabled. +- ID submission state is visible where ID confirmation is configured. +- International visitor fields are shown only when enabled. +- Submitted ID data can be confirmed or rejected where the workflow is available. +- Compliance fields remain hidden when disabled by settings. + +--- + +## Events + +Routes: `/#/entertainment/events`, `/#/entertainment/events/manage`, `/#/entertainment/events/manage/:id`, and `/#/entertainment/events/view/:id` - container `[app-events]` + +### US-080: Browse events +**As an** events coordinator +**I want to** view events in a list or calendar +**So that** I can see what is scheduled + +**Acceptance Criteria:** +- Events render as a list (`event-listing`) or calendar (`event-calendar`) when available. +- Events can be filtered by period and level where supported. +- Event cards or rows show title, date, time, location, and publication status. +- Empty states explain that there are no matching events. + +### US-081: Create and edit an event +**As an** events coordinator +**I want to** create and edit entertainment events +**So that** building occupants can discover them + +**Acceptance Criteria:** +- An Add control opens the event form (`app-event-manage`). +- Existing events can be edited at `/#/entertainment/events/manage/:id`. +- The form captures title, organiser, dates, timezone, attendance type, and access level. +- Events can be published as draft, open, or public where supported. +- Required fields are validated before saving. + +### US-082: View event details +**As a** concierge +**I want to** open event details +**So that** I can answer occupant questions about the event + +**Acceptance Criteria:** +- Event details are available at `/#/entertainment/events/view/:id`. +- The detail view shows schedule, location, organiser, description, and attendance information where available. +- Users can return from details to the event list. + +--- + +## Staff / Users + +Routes: `/#/users/staff` and `/#/users/staff/emergency-contacts` + +### US-090: Browse the staff directory +**As a** concierge +**I want to** search and filter the staff directory +**So that** I can look up colleagues and their details + +**Acceptance Criteria:** +- Staff are listed in the staff module. +- Staff can be searched by name or email. +- Staff can be filtered by zone or level where available. +- Selecting a staff member opens their details. +- Empty search results are shown clearly. + +### US-091: Manage emergency contacts +**As an** administrator +**I want to** manage emergency contacts +**So that** the right people are reachable in an emergency + +**Acceptance Criteria:** +- Emergency contacts are reachable at `/#/users/staff/emergency-contacts`. +- Contacts can be created, edited, and removed where supported. +- Contact details include enough information for emergency communication. +- Changes are saved only after confirmation or a successful save action. + +### US-092: Review staff details +**As a** concierge +**I want to** view staff details from the directory +**So that** I can support visitors, deliveries, and internal requests + +**Acceptance Criteria:** +- Staff details show identity, contact information, and location metadata where available. +- Missing optional staff fields display a graceful fallback. +- Staff lookup does not expose admin-only controls to non-admin users. + +--- + +## Facilities + +Route: `/#/facilities` + +### US-100: Monitor facility status +**As a** facilities manager +**I want to** see facility and room status across the building +**So that** I can respond to issues and occupancy in real time + +**Acceptance Criteria:** +- A status panel, list, or map shows facility availability. +- Results can be filtered by level or zone. +- Room event type filters can distinguish Internal, External, and Cancelled events where applicable. +- The page handles unavailable status data with an empty or error state. + +### US-101: Inspect facility details +**As a** facilities manager +**I want to** inspect a facility or room from the status view +**So that** I can understand the current issue or occupancy state + +**Acceptance Criteria:** +- Selecting a facility exposes details where supported. +- The detail view identifies the facility, level, status, and current booking or fault context where available. +- Returning to the facility list preserves the selected filters. + +--- + +## Reports + +Routes: `/#/reports`, `/#/reports/attendance`, `/#/reports/bookings`, `/#/reports/desks`, `/#/reports/parking`, `/#/reports/lockers`, `/#/reports/catering`, `/#/reports/contact-tracing`, `/#/reports/assets`, `/#/reports/visitors`, and `/#/reports/:id` + +### US-110: View operational reports +**As a** reporting user +**I want to** view reports across workplace operations +**So that** I can analyse building usage + +**Acceptance Criteria:** +- The Reports route defaults to the desks report when no specific child route is selected. +- Built-in report routes include attendance, room bookings, desks, parking, lockers, catering, contact tracing, assets, and visitors. +- Custom reports are available at `/#/reports/:id` when configured. +- Report pages share date range and level selection controls where applicable. +- Report generation shows loading state. + +### US-111: Generate, download, and print a report +**As a** reporting user +**I want to** generate, export, and print reports +**So that** I can distribute operational data + +**Acceptance Criteria:** +- A report can be generated for a selected date range. +- Level filters restrict the report when the report supports levels. +- Download and print actions are disabled until report data is available. +- Download exports the generated data in the report's supported format. +- Print mode hides controls that are not part of the report output. + +### US-112: Run attendance reports +**As a** facilities manager +**I want to** report on site attendance +**So that** I can understand occupancy for a selected period + +**Acceptance Criteria:** +- `/#/reports/attendance` allows selecting date range and level scope. +- Generate delegates to the attendance report service. +- Download exports the generated attendance data. +- Empty results show a no-data state rather than enabling a blank export. + +### US-113: Run booking utilisation reports +**As a** workplace administrator +**I want to** report on rooms, desks, parking, and lockers +**So that** I can measure resource utilisation + +**Acceptance Criteria:** +- Room booking reports include active and declined or cancelled booking states in summary counts. +- Desk reports count rejected bookings as declined. +- Parking and locker reports respect active date range and level filters. +- Report summaries and detail tables stay consistent with the same generated data. + +### US-114: Run catering, asset, visitor, and contact tracing reports +**As a** reporting user +**I want to** report on support services and compliance-sensitive workflows +**So that** I can audit activity beyond room and desk usage + +**Acceptance Criteria:** +- Catering reports include order and item views where supported. +- Asset reports include request, item, or utilisation summaries where supported. +- Visitor reports include visitor activity for the selected period. +- Contact tracing reports require a selected user before download is enabled. +- Contact tracing reports can be printed and downloaded when events are available. + +--- + +## Points Management + +Routes: `/#/points-management`, `/#/points-management/overview`, and `/#/points-management/assets` + +### US-120: Manage loyalty points and assets +**As an** administrator +**I want to** view points metrics and configure redeemable assets +**So that** I can run the rewards programme + +**Acceptance Criteria:** +- Overview and Assets tabs are available. +- Point metrics can be filtered by date. +- Point assets can be created and edited. +- Asset details include redemption value and display metadata where supported. +- The default points route redirects to Overview. + +### US-121: Review points performance +**As a** workplace administrator +**I want to** review points activity over time +**So that** I can understand programme adoption + +**Acceptance Criteria:** +- The Overview tab shows activity metrics for the selected period. +- Date changes update the displayed metrics. +- Empty metrics show zero values rather than stale data. + +--- + +## Surveys + +Routes: `/#/surveys`, `/#/surveys/list/:id`, `/#/surveys/builder`, `/#/surveys/builder/:id`, `/#/surveys/responses/:id`, and `/#/surveys/run/:id` + +### US-130: Build and review surveys +**As an** administrator +**I want to** create surveys and review responses +**So that** I can gather and analyse occupant feedback + +**Acceptance Criteria:** +- The Surveys landing view lists buildings or survey groupings. +- Surveys can be created with the builder route. +- Existing surveys can be edited with `/#/surveys/builder/:id`. +- Responses can be viewed with date filtering. +- Responses can be exported where supported. + +### US-131: Run a survey +**As an** occupant or visitor +**I want to** complete a survey from a run link +**So that** I can submit feedback without admin navigation + +**Acceptance Criteria:** +- `/#/surveys/run/:id` opens the survey outlet. +- The survey can be submitted once required questions are complete. +- The run view is focused on the survey and does not require the user to navigate admin lists. + +--- + +## Building Configuration + +### US-140: Manage the zone hierarchy +**As an** administrator +**I want to** manage regions, buildings, and levels +**So that** the organisational hierarchy reflects the real estate + +**Acceptance Criteria:** +- Route `/#/zone-management` exposes Regions, Buildings, and Levels management. +- Regions, buildings, and levels can be created, edited, and deleted where supported. +- Active building and region context affects feature routes that depend on location. +- Legacy routes `/#/level-management`, `/#/building-management`, and `/#/region-management` redirect to `/#/zone-management`. +- App-wide settings for Workplace, Concierge, Booking Panel, and Visitor Kiosk can be configured. + +### US-141: Manage rooms +**As an** administrator +**I want to** edit room configuration, alerts, and booking rules +**So that** rooms are bookable and correctly described + +**Acceptance Criteria:** +- Route `/#/room-management` (`[app-new-room-manager]`) lists rooms with an Add action. +- Rooms can be created and edited. +- Room details include name, capacity, level, map ID, features, and bookability metadata where supported. +- Room alerts can be set and edited. +- Room booking history can be viewed. +- Room booking rules can be managed. +- Changes refresh the room list after saving. + +### US-142: Manage points of interest +**As an** administrator +**I want to** create and manage points of interest on the map +**So that** occupants can find amenities and services + +**Acceptance Criteria:** +- Route `/#/points-of-interest` lists POIs. +- POIs can be created, edited, and deleted. +- POIs include map or level positioning data where required. +- Search or filtering helps locate existing POIs. + +### US-143: Configure app settings +**As an** administrator +**I want to** manage concierge and related app settings +**So that** features, defaults, and workflow rules match the building + +**Acceptance Criteria:** +- Settings modals load inherited and zone-specific metadata before editing. +- Concierge settings include feature list, feature groups, default route, booking options, report options, and module-specific toggles where configured. +- Workplace, Booking Panel, and Visitor Kiosk settings can be edited from the same app settings area. +- Saving settings writes only intended changes and reports failures. + +--- + +## Communications & Content + +### US-150: Manage digital signage +**As an** administrator +**I want to** manage signage media, displays, and zones +**So that** the right content shows on the right screens + +**Acceptance Criteria:** +- Route `/#/signage` exposes Media, Displays, and Zones tabs. +- `/#/signage` redirects to `/#/signage/media` by default. +- Media can be uploaded, searched, previewed, and organised where supported. +- Playlists can be created and assigned to displays or zones. +- Displays can be created, edited, and associated with signage zones. +- Playlists can be approved where approval is part of the workflow. + +### US-151: Manage email templates +**As an** administrator +**I want to** design email templates for automated notifications +**So that** building communications are on-brand and clear + +**Acceptance Criteria:** +- Route `/#/email-templates` lists templates. +- Templates can be created at `/#/email-templates/manage`. +- Templates can be edited at `/#/email-templates/manage/:id`. +- Templates select a trigger event. +- Templates configure From, Reply-To, subject, and HTML body with placeholders. +- Validation prevents saving templates without required routing or content fields. + +### US-152: Manage deals and offers +**As an** events coordinator +**I want to** manage promotional deals and offers +**So that** occupants are aware of available offers + +**Acceptance Criteria:** +- Route `/#/deals-n-offers` lists deals. +- Deals can be created at `/#/deals-n-offers/manage`. +- Deals can be edited at `/#/deals-n-offers/manage/:id`. +- Deals can be deleted where supported. +- Deal details include title, dates, description, and publication metadata where supported. + +### US-153: Manage short URLs +**As an** administrator +**I want to** create and manage short URLs +**So that** I can share trackable links for building services + +**Acceptance Criteria:** +- Route `/#/url-management` lists short URLs. +- URLs can be created, edited, deleted, and searched. +- New URLs validate destination and short-code fields before saving. +- Editing preserves existing link analytics or metadata where supported. + +--- + +## Cross-Cutting Behaviour + +### US-160: Work in mock mode +**As a** tester +**I want to** open concierge routes with `?mock=true` +**So that** I can validate workflows without a live backend session + +**Acceptance Criteria:** +- `?mock=true` initialises mock data and bypasses normal authentication for supported e2e flows. +- Mock mode works on direct deep links such as `/#/book/parking/events/list?mock=true`. +- Mock mode preserves route and query parameters after the app initialises. + +### US-161: Keep date, level, and search state predictable +**As a** concierge +**I want** filters to behave consistently across modules +**So that** I can move through the app without losing context unexpectedly + +**Acceptance Criteria:** +- Date selectors update the current module's date-dependent results. +- Level selectors use active building or region scope based on settings. +- Search filters update visible lists without changing unrelated filters. +- Query parameters restore supported filters on reload. +- Invalid query parameters are ignored or normalised rather than causing repeated navigation. + +### US-162: Handle empty, loading, and error states +**As a** user +**I want to** understand when data is loading, absent, or failed +**So that** I do not mistake app state for missing operational work + +**Acceptance Criteria:** +- Long-running operations show loading indicators. +- Empty lists show a clear no-data state. +- Failed create, update, delete, import, export, approve, or reject actions report the failure. +- Failed operations do not silently discard user input where recovery is possible. + +### US-163: Support admin-only configuration safely +**As a** workplace administrator +**I want** admin-only workflows to be clearly separated +**So that** front-of-house users cannot accidentally change configuration + +**Acceptance Criteria:** +- Non-admin users do not see the Management sidebar group. +- Admin routes with guards still require authorised loading. +- Destructive actions use confirmation where the app supports confirmation. +- Saved configuration updates refresh affected lists or settings-backed controls. + +### US-164: Export operational records +**As a** reporting user +**I want to** export operational data from supported modules +**So that** I can share records outside Concierge + +**Acceptance Criteria:** +- Export actions are available only when the module supports export. +- Exported data reflects the active filters where supported. +- Export buttons are disabled or no-op with a warning when there is no data. +- Generated file names identify the exported module or template. diff --git a/apps/concierge/e2e/admin-content.spec.ts b/apps/concierge/e2e/admin-content.spec.ts new file mode 100644 index 0000000000..c6d7c22092 --- /dev/null +++ b/apps/concierge/e2e/admin-content.spec.ts @@ -0,0 +1,135 @@ +import { expect, test } from './fixtures'; +import { gotoMockRoute, waitForAppShell, waitForHost } from './helpers'; + +// US-090 / US-091 / US-092: Staff directory and emergency contacts. +test.describe('Staff and Emergency Contacts', () => { + test('shows staff directory and emergency contacts routes', async ({ + page, + }) => { + await gotoMockRoute(page, '/#/users/staff'); + await waitForAppShell(page); + await waitForHost(page, '[app-new-staff]'); + await waitForHost(page, 'staff-topbar'); + await waitForHost(page, 'staff-listings'); + + await gotoMockRoute(page, '/#/users/staff/emergency-contacts'); + await waitForAppShell(page); + await waitForHost(page, '[app-emergency-contacts]'); + }); +}); + +// US-120 / US-121: Points overview and assets. +test.describe('Points Management', () => { + test('shows points overview and assets tabs', async ({ page }) => { + await gotoMockRoute(page, '/#/points-management/overview'); + await waitForAppShell(page); + await waitForHost(page, 'placeos-new-points'); + await waitForHost(page, 'points-overview'); + + await gotoMockRoute(page, '/#/points-management/assets'); + await waitForHost(page, 'points-assets'); + await expect( + page.getByRole('button', { name: /Add|New/i }).first(), + ).toBeVisible(); + }); +}); + +// US-130 / US-131: Survey list, builder and run outlet. +test.describe('Surveys', () => { + test('shows survey list and builder routes', async ({ page }) => { + await gotoMockRoute(page, '/#/surveys'); + await waitForAppShell(page); + await waitForHost(page, 'app-survey'); + await waitForHost(page, 'building-list'); + + await gotoMockRoute(page, '/#/surveys/builder'); + await waitForHost(page, 'survey-builder'); + }); +}); + +// US-140 / US-142 / US-143: Building, zone, POI and settings management. +test.describe('Building Configuration', () => { + test('shows zone manager and app settings actions', async ({ page }) => { + await gotoMockRoute(page, '/#/zone-management'); + await waitForAppShell(page); + await waitForHost(page, '[app-zone-manager]'); + + await expect(page.getByRole('tab', { name: /Regions/i })).toBeVisible(); + await expect( + page.getByRole('tab', { name: /Buildings/i }), + ).toBeVisible(); + await expect(page.getByRole('tab', { name: /Levels/i })).toBeVisible(); + await page + .locator('main header button', { hasText: 'more_vert' }) + .click(); + await expect( + page.getByRole('menuitem', { name: /Concierge/i }), + ).toBeVisible(); + }); + + test('redirects legacy hierarchy routes to zone management', async ({ + page, + }) => { + for (const route of [ + '/#/level-management', + '/#/building-management', + '/#/region-management', + ]) { + await gotoMockRoute(page, route); + await waitForHost(page, '[app-zone-manager]'); + expect(page.url()).toContain('/zone-management'); + } + }); + + test('shows points of interest manager', async ({ page }) => { + await gotoMockRoute(page, '/#/points-of-interest'); + await waitForAppShell(page); + await waitForHost(page, '[app-poi-manager]'); + await waitForHost(page, 'poi-list'); + }); +}); + +// US-150 / US-151 / US-152 / US-153: Content and communications management. +test.describe('Communications and Content', () => { + test('shows signage media, displays and zones tabs', async ({ page }) => { + const routes = [ + { route: '/#/signage/media', selector: 'signage-media' }, + { route: '/#/signage/displays', selector: 'signage-displays' }, + { route: '/#/signage/zones', selector: 'signage-zones' }, + ]; + + for (const { route, selector } of routes) { + await gotoMockRoute(page, route); + await waitForAppShell(page); + await waitForHost(page, '[app-new-staff]'); + await waitForHost(page, selector); + } + }); + + test('shows email template list and manage routes', async ({ page }) => { + await gotoMockRoute(page, '/#/email-templates'); + await waitForAppShell(page); + await waitForHost(page, '[app-email-templates]'); + await waitForHost(page, 'email-templates-list'); + + await gotoMockRoute(page, '/#/email-templates/manage'); + await waitForHost(page, 'email-template-manage'); + }); + + test('shows deals list and manage routes', async ({ page }) => { + await gotoMockRoute(page, '/#/deals-n-offers'); + await waitForAppShell(page); + await waitForHost(page, '[app-deals]'); + await waitForHost(page, 'deals-list'); + + await gotoMockRoute(page, '/#/deals-n-offers/manage'); + await waitForHost(page, 'deals-manage'); + }); + + test('shows short URL manager', async ({ page }) => { + await gotoMockRoute(page, '/#/url-management'); + await waitForAppShell(page); + await waitForHost(page, '[app-short-url-manager]'); + await waitForHost(page, 'short-url-list'); + }); +}); diff --git a/apps/concierge/e2e/assets.spec.ts b/apps/concierge/e2e/assets.spec.ts new file mode 100644 index 0000000000..104bf8005d --- /dev/null +++ b/apps/concierge/e2e/assets.spec.ts @@ -0,0 +1,84 @@ +import { expect, test } from './fixtures'; +import { gotoMockRoute, waitForAppShell, waitForHost } from './helpers'; + +// US-050 / US-052 / US-053: Asset listings, item browsing and purchase orders. +test.describe('Asset Bookings Page', () => { + test('shows asset request, item and purchase order listings', async ({ + page, + }) => { + const routes = [ + { + route: '/#/book/assets/list/requests', + selector: 'app-asset-request-list', + }, + { + route: '/#/book/assets/list/items', + selector: 'app-asset-item-list', + }, + { + route: '/#/book/assets/list/purchase-orders', + selector: 'app-asset-purchase-order-list', + }, + ]; + + for (const { route, selector } of routes) { + await gotoMockRoute(page, route); + await waitForAppShell(page); + await waitForHost(page, '[asset-manager]'); + await waitForHost(page, 'asset-listing'); + await waitForHost(page, 'asset-manager-topbar'); + await waitForHost(page, selector); + } + }); + + test('shows grid/list controls for asset items', async ({ page }) => { + await gotoMockRoute(page, '/#/book/assets/list/items'); + await waitForHost(page, 'app-asset-item-list'); + + await expect( + page.locator('asset-manager-topbar button icon', { + hasText: 'view_module', + }), + ).toBeVisible(); + await expect( + page.locator('asset-manager-topbar button icon', { + hasText: 'view_list', + }), + ).toBeVisible(); + }); +}); + +// US-051 / US-053: Asset inventory, bulk import and purchase order management. +test.describe('Asset Management Routes', () => { + test('shows asset management forms', async ({ page }) => { + const routes = [ + { + route: '/#/book/assets/manage/group', + selector: 'asset-group-form', + }, + { + route: '/#/book/assets/manage/asset', + selector: 'asset-form', + }, + { + route: '/#/book/assets/manage/asset-bulk', + selector: 'asset-bulk-form', + }, + { + route: '/#/book/assets/manage/category', + selector: 'asset-category-form', + }, + { + route: '/#/book/assets/manage/purchase-order', + selector: 'asset-purchase-order-form', + }, + ]; + + for (const { route, selector } of routes) { + await gotoMockRoute(page, route); + await waitForAppShell(page); + await waitForHost(page, '[asset-manager]'); + await waitForHost(page, selector); + } + }); +}); diff --git a/apps/concierge/e2e/catering.spec.ts b/apps/concierge/e2e/catering.spec.ts new file mode 100644 index 0000000000..038b83c5ac --- /dev/null +++ b/apps/concierge/e2e/catering.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from './fixtures'; + +// US-060 / US-061: Viewing catering orders and the catering menu. +test.describe('Catering Page', () => { + test('shows catering orders view', async ({ page }) => { + await page.goto('/#/book/catering/orders?mock=true'); + await page.locator('[app-catering]').waitFor({ timeout: 30000 }); + + await expect(page.locator('app-topbar')).toBeVisible(); + await expect(page.locator('catering-topbar')).toBeVisible(); + }); + + test('shows catering menu view', async ({ page }) => { + await page.goto('/#/book/catering/menu?mock=true'); + await page.locator('[app-catering]').waitFor({ timeout: 30000 }); + + await expect(page.locator('catering-topbar')).toBeVisible(); + }); +}); diff --git a/apps/concierge/e2e/dayview.spec.ts b/apps/concierge/e2e/dayview.spec.ts index 53fda1882a..ad254eeb17 100644 --- a/apps/concierge/e2e/dayview.spec.ts +++ b/apps/concierge/e2e/dayview.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from './fixtures'; test.describe('Day View Page', () => { test('has layout', async ({ page }) => { diff --git a/apps/concierge/e2e/desks.spec.ts b/apps/concierge/e2e/desks.spec.ts new file mode 100644 index 0000000000..19106708d7 --- /dev/null +++ b/apps/concierge/e2e/desks.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from './fixtures'; +import { gotoMockRoute, waitForHost } from './helpers'; + +// US-020 / US-023: Viewing desk bookings and managing the desk inventory. +test.describe('Desk Bookings Page', () => { + test('shows desk bookings view', async ({ page }) => { + await page.goto('/#/book/desks/events?mock=true'); + await page.locator('[app-desks]').waitFor({ timeout: 30000 }); + + await expect(page.locator('app-topbar')).toBeVisible(); + await expect(page.locator('desk-bookings')).toBeVisible(); + }); + + test('shows desk management view', async ({ page }) => { + await page.goto('/#/book/desks/manage?mock=true'); + await page.locator('[app-desks]').waitFor({ timeout: 30000 }); + + await expect(page.locator('desks-manage')).toBeVisible(); + }); + + // US-024 / US-025: Desk import/export controls and map route. + test('shows desk map and management import/export controls', async ({ + page, + }) => { + await gotoMockRoute(page, '/#/book/desks/map'); + await waitForHost(page, '[app-desks]'); + await waitForHost(page, 'desk-map-view'); + + await gotoMockRoute(page, '/#/book/desks/manage'); + await waitForHost(page, 'desks-manage'); + await expect( + page.getByRole('button', { name: /cloud_upload/i }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: /^download$/i }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: /^lock_open$/i }), + ).toBeVisible(); + }); +}); diff --git a/apps/concierge/e2e/events.spec.ts b/apps/concierge/e2e/events.spec.ts new file mode 100644 index 0000000000..aa9ad16e87 --- /dev/null +++ b/apps/concierge/e2e/events.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from './fixtures'; +import { gotoMockRoute, waitForHost } from './helpers'; + +// US-080: Browsing entertainment events. +test.describe('Events Page', () => { + test('shows the events listing', async ({ page }) => { + await page.goto('/#/entertainment/events?mock=true'); + await page.locator('[app-events]').waitFor({ timeout: 30000 }); + + await expect(page.locator('app-topbar')).toBeVisible(); + await expect(page.locator('event-listing')).toBeVisible(); + }); + + // US-081 / US-082: Event create/edit and detail routes. + test('shows event manage route', async ({ page }) => { + await gotoMockRoute(page, '/#/entertainment/events/manage'); + await waitForHost(page, 'app-event-manage'); + }); +}); diff --git a/apps/concierge/e2e/fixtures.ts b/apps/concierge/e2e/fixtures.ts new file mode 100644 index 0000000000..2beabc0c45 --- /dev/null +++ b/apps/concierge/e2e/fixtures.ts @@ -0,0 +1,28 @@ +import { test as base, expect } from '@playwright/test'; + +/** + * Shared test fixture for the concierge e2e suite. + * + * The app only registers its mock API handlers when `localStorage['mock']` is + * `'true'` at boot (see `PlaceOS_Service.init` -> `_mocks()`). The `?mock=true` + * URL param sets that key, but only later during `setupPlace`, so on a fresh + * browser context (as Playwright uses for every test) the handlers are not + * registered on the first load and organisation data fails to load. + * + * Seeding the key via an init script — which runs before the app bootstraps — + * guarantees the mocks are registered on the very first navigation. + */ +export const test = base.extend({ + page: async ({ page }, use) => { + await page.addInitScript(() => { + try { + window.localStorage.setItem('mock', 'true'); + } catch { + /* localStorage may be unavailable before navigation */ + } + }); + await use(page); + }, +}); + +export { expect }; diff --git a/apps/concierge/e2e/helpers.ts b/apps/concierge/e2e/helpers.ts new file mode 100644 index 0000000000..60a7413bad --- /dev/null +++ b/apps/concierge/e2e/helpers.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test'; + +export async function gotoMockRoute(page: Page, route: string) { + const separator = route.includes('?') ? '&' : '?'; + await page.goto(`${route}${separator}mock=true`); +} + +export async function waitForAppShell(page: Page) { + await page.locator('app-topbar').first().waitFor({ timeout: 30000 }); + await page.locator('app-sidebar').first().waitFor({ timeout: 30000 }); +} + +export async function waitForHost(page: Page, selector: string) { + await page + .locator(selector) + .first() + .waitFor({ state: 'attached', timeout: 30000 }); +} diff --git a/apps/concierge/e2e/lockers.spec.ts b/apps/concierge/e2e/lockers.spec.ts index 8ed016d230..cf71b632f5 100644 --- a/apps/concierge/e2e/lockers.spec.ts +++ b/apps/concierge/e2e/lockers.spec.ts @@ -1,6 +1,19 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from './fixtures'; +import { gotoMockRoute, waitForHost } from './helpers'; test.describe('Locker Management Page', () => { + // US-040 / US-043: Locker bookings route and approval toolbar context. + test('shows locker bookings view', async ({ page }) => { + await gotoMockRoute(page, '/#/book/lockers/events'); + await waitForHost(page, '[app-lockers]'); + await waitForHost(page, 'lockers-topbar'); + await waitForHost(page, 'locker-bookings'); + + await expect( + page.getByRole('button', { name: /New Booking/i }), + ).toBeVisible(); + }); + test('creates a locker bank and child locker with mocks', async ({ page, }) => { @@ -55,4 +68,24 @@ test.describe('Locker Management Page', () => { await page.getByRole('button', { name: 'chevron_right' }).click(); await expect(page.getByText(locker_name)).toBeVisible(); }); + + // US-042 / US-044: Locker bulk release and booking rule controls. + test('shows locker management bulk and rules controls', async ({ + page, + }) => { + await gotoMockRoute(page, '/#/book/lockers/manage'); + await waitForHost(page, '[app-lockers]'); + await waitForHost(page, 'locker-list'); + + await expect( + page.locator('lockers-topbar button icon', { + hasText: 'open_in_new', + }), + ).toBeVisible(); + await expect( + page.locator('lockers-topbar button icon', { + hasText: 'lock_open', + }), + ).toBeVisible(); + }); }); diff --git a/apps/concierge/e2e/navigation.spec.ts b/apps/concierge/e2e/navigation.spec.ts new file mode 100644 index 0000000000..2bca4f1683 --- /dev/null +++ b/apps/concierge/e2e/navigation.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from './fixtures'; +import { gotoMockRoute, waitForHost } from './helpers'; + +// US-001 / US-002 / US-003: Application shell, default landing route and sidebar navigation. +test.describe('Application Shell', () => { + test('lands on room bookings with shell visible', async ({ page }) => { + // Empty route should redirect to the configured default route (/book/rooms) + await page.goto('/#/?mock=true'); + await page.locator('[app-new-dayview]').waitFor({ timeout: 30000 }); + + await expect(page.locator('app-topbar')).toBeVisible(); + await expect(page.locator('app-sidebar')).toBeVisible(); + }); + + test('sidebar links navigate between feature areas', async ({ page }) => { + await page.goto('/#/?mock=true'); + await page.locator('app-sidebar').waitFor({ timeout: 30000 }); + + // Desk bookings is always available in the bookings group. + await page + .locator('app-sidebar a[href*="/book/desks"]') + .first() + .click(); + await page.locator('[app-desks]').waitFor({ timeout: 30000 }); + expect(page.url()).toContain('/book/desks'); + + // Navigating back to room bookings returns to the day view. + await page + .locator('app-sidebar a[href*="/book/rooms"]') + .first() + .click(); + await page.locator('[app-new-dayview]').waitFor({ timeout: 30000 }); + expect(page.url()).toContain('/book/rooms'); + }); + + // US-002 / US-004 / US-160: Sidebar exposes configured feature areas. + test('shows configured sidebar feature groups and links', async ({ + page, + }) => { + await gotoMockRoute(page, '/#/book/rooms'); + await waitForHost(page, 'app-sidebar'); + + await expect( + page.locator('app-sidebar a[href*="/book/rooms"]'), + ).toBeVisible(); + await expect( + page.locator('app-sidebar a[href*="/book/desks/events"]'), + ).toBeVisible(); + await expect( + page.locator('app-sidebar a[href*="/book/parking/events"]'), + ).toBeVisible(); + await expect( + page.locator('app-sidebar a[href*="/reports/attendance"]'), + ).toBeVisible(); + await expect( + page.locator('app-sidebar a[href*="/zone-management"]'), + ).toBeVisible(); + }); +}); diff --git a/apps/concierge/e2e/parking.spec.ts b/apps/concierge/e2e/parking.spec.ts new file mode 100644 index 0000000000..b306d2e0c6 --- /dev/null +++ b/apps/concierge/e2e/parking.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from './fixtures'; +import { gotoMockRoute, waitForHost } from './helpers'; + +// US-030: Viewing parking bookings. +test.describe('Parking Bookings Page', () => { + test('shows parking bookings list', async ({ page }) => { + await page.goto('/#/book/parking/events/list?mock=true'); + await page.locator('app-parking').waitFor({ timeout: 30000 }); + + await expect(page.locator('app-topbar')).toBeVisible(); + await expect(page.locator('parking-topbar')).toBeVisible(); + await expect(page.locator('parking-bookings-list')).toBeVisible(); + }); + + // US-033 / US-035: Request routes fall back safely when requests are disabled. + test('shows booking list for request routes when parking requests are disabled', async ({ + page, + }) => { + await gotoMockRoute(page, '/#/book/parking/events/requests'); + await waitForHost(page, 'app-parking'); + await waitForHost(page, 'parking-bookings-list'); + + await expect(page.locator('parking-requests-list')).toHaveCount(0); + }); + + // US-030 / US-035: Map view uses a single selected level. + test('shows parking map view', async ({ page }) => { + await gotoMockRoute(page, '/#/book/parking/events/map'); + await waitForHost(page, 'app-parking'); + await waitForHost(page, 'parking-map'); + + expect(page.url()).toContain('/book/parking/events/map'); + }); + + // US-032 / US-034: Parking management tabs and CSV controls. + test('shows parking management views', async ({ page }) => { + const routes = [ + { + route: '/#/book/parking/manage/spaces', + selector: 'parking-space-list', + }, + { + route: '/#/book/parking/manage/users', + selector: 'parking-users-list', + }, + { + route: '/#/book/parking/manage/fleet', + selector: 'parking-fleet-list', + }, + { route: '/#/book/parking/manage/map', selector: 'parking-map' }, + ]; + + for (const { route, selector } of routes) { + await gotoMockRoute(page, route); + await waitForHost(page, 'app-parking'); + await waitForHost(page, selector); + } + + await gotoMockRoute(page, '/#/book/parking/manage/spaces'); + await waitForHost(page, 'parking-space-list'); + await expect( + page.locator('parking-topbar button icon', { hasText: 'upload' }), + ).toBeVisible(); + await expect( + page.locator('parking-topbar button icon', { hasText: 'download' }), + ).toBeVisible(); + await expect( + page.locator('parking-topbar button icon', { + hasText: 'lock_open', + }), + ).toBeVisible(); + }); +}); diff --git a/apps/concierge/e2e/reports.spec.ts b/apps/concierge/e2e/reports.spec.ts new file mode 100644 index 0000000000..1fe0c55928 --- /dev/null +++ b/apps/concierge/e2e/reports.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from './fixtures'; +import { gotoMockRoute, waitForAppShell, waitForHost } from './helpers'; + +// US-110: Built-in report routes and default report routing. +test.describe('Reports Page', () => { + test('defaults to the desks report', async ({ page }) => { + await gotoMockRoute(page, '/#/reports'); + await waitForAppShell(page); + await waitForHost(page, '[app-new-reports]'); + await waitForHost(page, '[report-desks]'); + + expect(page.url()).toContain('/reports/desks'); + }); + + test('shows built-in report routes', async ({ page }) => { + const reports = [ + { + route: '/#/reports/attendance', + selector: '[site-attendance-report]', + }, + { route: '/#/reports/bookings', selector: '[report-spaces]' }, + { route: '/#/reports/desks', selector: '[report-desks]' }, + { route: '/#/reports/parking', selector: '[parking-report]' }, + { route: '/#/reports/lockers', selector: '[lockers-report]' }, + { route: '/#/reports/catering', selector: 'catering-report' }, + { + route: '/#/reports/contact-tracing', + selector: 'app-contact-tracing-report', + }, + { route: '/#/reports/assets', selector: '[report-assets]' }, + { route: '/#/reports/visitors', selector: '[visitors-report]' }, + ]; + + for (const { route, selector } of reports) { + await gotoMockRoute(page, route); + await waitForAppShell(page); + await waitForHost(page, '[app-new-reports]'); + await waitForHost(page, selector); + } + }); + + // US-111: Report exports are gated until generated data exists. + test('disables generic report download and print until data exists', async ({ + page, + }) => { + await gotoMockRoute(page, '/#/reports/desks'); + await waitForHost(page, 'reports-options'); + + await expect( + page.locator('reports-options button[icon]').nth(0), + ).toBeDisabled(); + await expect( + page.locator('reports-options button[icon]').nth(1), + ).toBeDisabled(); + }); + + // US-114: Contact tracing requires a selected user before export/print. + test('disables contact tracing export actions until a user is selected', async ({ + page, + }) => { + await gotoMockRoute(page, '/#/reports/contact-tracing'); + await waitForHost(page, 'contact-tracing-options'); + + await expect( + page.locator('contact-tracing-options button[icon]').nth(0), + ).toBeDisabled(); + await expect( + page.locator('contact-tracing-options button[icon]').nth(1), + ).toBeDisabled(); + }); +}); diff --git a/apps/concierge/e2e/room-management.spec.ts b/apps/concierge/e2e/room-management.spec.ts new file mode 100644 index 0000000000..e5c9cf5346 --- /dev/null +++ b/apps/concierge/e2e/room-management.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from './fixtures'; + +// US-141: Managing room configuration. +test.describe('Room Management Page', () => { + test('shows the room list', async ({ page }) => { + await page.goto('/#/room-management?mock=true'); + await page + .locator('[app-new-room-manager]') + .waitFor({ timeout: 30000 }); + + await expect(page.locator('app-topbar')).toBeVisible(); + await expect(page.locator('room-manager-topbar')).toBeVisible(); + await expect(page.locator('room-list')).toBeVisible(); + }); +}); diff --git a/apps/concierge/e2e/visitors.spec.ts b/apps/concierge/e2e/visitors.spec.ts new file mode 100644 index 0000000000..0592479a35 --- /dev/null +++ b/apps/concierge/e2e/visitors.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from './fixtures'; +import { gotoMockRoute, waitForHost } from './helpers'; + +// US-070 / US-071: Viewing visitors and opening the invite visitor flow. +test.describe('Visitors Page', () => { + test('shows the visitor listing', async ({ page }) => { + await page.goto('/#/book/visitors?mock=true'); + await page.locator('[app-new-visitors]').waitFor({ timeout: 30000 }); + + await expect(page.locator('app-topbar')).toBeVisible(); + await expect(page.locator('guest-listings')).toBeVisible(); + }); + + test('opens the invite visitor dialog', async ({ page }) => { + await page.goto('/#/book/visitors?mock=true'); + await page.locator('[app-new-visitors]').waitFor({ timeout: 30000 }); + + await page + .getByRole('button', { name: 'Invite Visitor' }) + .first() + .click(); + // The invite modal renders inside a fullscreen shell whose host has no + // box; assert on its heading (the page's only other heading is + // "Visitors") rather than the zero-size dialog/shell host. + await expect( + page.getByRole('heading', { name: 'Invite Visitor' }), + ).toBeVisible({ timeout: 10000 }); + }); + + // US-070 / US-073 / US-074 / US-076: Visitor listing detail columns. + test('shows visitor operational columns', async ({ page }) => { + await gotoMockRoute(page, '/#/book/visitors'); + await waitForHost(page, '[app-new-visitors]'); + await waitForHost(page, 'guest-listings'); + + await expect( + page + .locator('guest-listings') + .getByRole('button', { name: /^Status$/ }), + ).toBeVisible(); + await expect( + page + .locator('guest-listings') + .getByRole('button', { name: /Visitor/i }), + ).toBeVisible(); + await expect( + page + .locator('guest-listings') + .getByRole('button', { name: /Host/i }), + ).toBeVisible(); + await expect( + page + .locator('guest-listings') + .getByRole('button', { name: /Checked-in/i }), + ).toBeVisible(); + await expect( + page + .locator('guest-listings') + .getByRole('button', { name: /Checked-out/i }), + ).toBeVisible(); + }); +}); diff --git a/apps/concierge/src/app/asset-manager/asset-category-form.component.ts b/apps/concierge/src/app/asset-manager/asset-category-form.component.ts index dec5cc2404..d826cfe931 100644 --- a/apps/concierge/src/app/asset-manager/asset-category-form.component.ts +++ b/apps/concierge/src/app/asset-manager/asset-category-form.component.ts @@ -9,6 +9,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; +import { Router } from '@angular/router'; import { generateAssetCategoryForm, saveAssetCategory } from '@placeos/assets'; import { AssetCategory, @@ -110,8 +111,11 @@ import { AssetManagerStateService } from './asset-manager-state.service'; }) export class AssetCategoryFormComponent { private _state = inject(AssetManagerStateService); - private _dialog_ref = - inject>(MatDialogRef); + private _dialog_ref = inject>( + MatDialogRef, + { optional: true }, + ); + private _router = inject(Router); public readonly loading = signal(false); private readonly _form_ref = generateAssetCategoryForm(); @@ -127,7 +131,7 @@ export class AssetCategoryFormComponent { constructor() { const _data = inject<{ category?: AssetCategory; - }>(MAT_DIALOG_DATA); + }>(MAT_DIALOG_DATA, { optional: true }); if (_data?.category) patchSignalModel(this.model, _data.category); } @@ -135,11 +139,11 @@ export class AssetCategoryFormComponent { public async save() { if (!this.form().valid()) return; this.loading.set(true); - this._dialog_ref.disableClose = true; + if (this._dialog_ref) this._dialog_ref.disableClose = true; const data = this.model(); const item = await saveAssetCategory(data).catch((e) => { this.loading.set(false); - this._dialog_ref.disableClose = false; + if (this._dialog_ref) this._dialog_ref.disableClose = false; notifyError( i18n('APP.CONCIERGE.ASSETS_CATEGORY_SAVE_ERROR', { error: e.message, @@ -149,6 +153,10 @@ export class AssetCategoryFormComponent { }); this.form().reset(); this.loading.set(false); - this._dialog_ref.close(item); + if (this._dialog_ref) { + this._dialog_ref.close(item); + } else { + await this._router.navigate(['/book/assets/list/items']); + } } } diff --git a/apps/concierge/src/app/desks/desk-modal.component.ts b/apps/concierge/src/app/desks/desk-modal.component.ts index 39a4d072c4..1c718cbd3d 100644 --- a/apps/concierge/src/app/desks/desk-modal.component.ts +++ b/apps/concierge/src/app/desks/desk-modal.component.ts @@ -238,6 +238,12 @@ export interface DeskModalData { [placeholder]="'COMMON.FEATURES' | translate" [formField]="form.features" > + + + + + + @@ -325,11 +343,13 @@ export class DeskModalComponent implements OnInit { map_id: '', groups: [] as string[], features: [] as string[], + tags: [] as string[], bookable: false, notes: '', assigned_user: null as User | null, assigned_to: '', assigned_name: '', + homebase: '', security: '', zone_id: this._data?.zone_id || '', }); @@ -350,10 +370,12 @@ export class DeskModalComponent implements OnInit { map_id: desk.map_id ?? m.map_id, groups: desk.groups ?? m.groups, features: desk.features ?? m.features, + tags: desk.tags ?? m.tags, bookable: desk.bookable ?? m.bookable, notes: desk.notes ?? m.notes, assigned_to: desk.assigned_to ?? m.assigned_to, assigned_name: desk.assigned_name ?? m.assigned_name, + homebase: desk.homebase ?? m.homebase, security: desk.security ?? m.security, })); } diff --git a/apps/concierge/src/app/desks/desks-manage.component.ts b/apps/concierge/src/app/desks/desks-manage.component.ts index 28b47ead64..ff23fe231b 100644 --- a/apps/concierge/src/app/desks/desks-manage.component.ts +++ b/apps/concierge/src/app/desks/desks-manage.component.ts @@ -63,10 +63,19 @@ import { DeskQrItem, DesksStateService } from './desks-state.service'; name: 'COMMON.FEATURES' | translate, content: item_list_template, }, + { + key: 'tags', + name: 'COMMON.TAGS' | translate, + content: item_list_template, + }, { key: 'security', name: 'APP.CONCIERGE.DESKS_SECURITY' | translate, }, + { + key: 'homebase', + name: 'APP.CONCIERGE.DESKS_HOMEBASE' | translate, + }, { key: 'bookable', name: 'COMMON.BOOKABLE' | translate, diff --git a/apps/concierge/src/app/desks/desks-state.service.ts b/apps/concierge/src/app/desks/desks-state.service.ts index c3617e1966..4f504200ef 100644 --- a/apps/concierge/src/app/desks/desks-state.service.ts +++ b/apps/concierge/src/app/desks/desks-state.service.ts @@ -454,6 +454,8 @@ export class DesksStateService extends AsyncHandler { bookable: true, groups: ['test-desk-group', 'desk-bookers'], features: ['Standing Desk', 'Dual Monitor'], + tags: ['engineering', 'level-3'], + homebase: 'Sydney HQ', }), ] ).map((desk) => { diff --git a/apps/concierge/src/app/parking/parking-assign-space-modal.component.ts b/apps/concierge/src/app/parking/parking-assign-space-modal.component.ts index f8213de2d0..5fa804d8ce 100644 --- a/apps/concierge/src/app/parking/parking-assign-space-modal.component.ts +++ b/apps/concierge/src/app/parking/parking-assign-space-modal.component.ts @@ -192,7 +192,7 @@ export function bookedResourcePeriodForBooking(booking: Booking) {
-
{{ space.name || space.id }}
+
{{ space_label(space) }}
} @@ -398,6 +398,7 @@ export class ParkingAssignSpaceModalComponent hover: true, data: { ...space, + name: this.space_label(space), user: space.assigned_to || '', plate_number: '', status: is_selected @@ -440,6 +441,14 @@ export class ParkingAssignSpaceModalComponent this._refreshStyles(); } + public readonly space_label = (space: PlaceAsset) => + space?.identifier || + space?.name || + (space as any)?.display_name || + (space as any)?.metadata?.display_name || + (space as any)?.extension_data?.display_name || + ''; + public async confirmAssign() { const space = this.selected_space(); const level = this.selected_level(); @@ -453,7 +462,8 @@ export class ParkingAssignSpaceModalComponent } this.loading.set(true); try { - const asset_name = space.name || space.id; + const asset_name = + this.space_label(space) || space.name || space.id; await updateBooking(this._data.booking.id, { asset_id: space.id, asset_name, diff --git a/apps/concierge/src/app/parking/parking-bookings-list.component.ts b/apps/concierge/src/app/parking/parking-bookings-list.component.ts index 923c239e32..907a82d60e 100644 --- a/apps/concierge/src/app/parking/parking-bookings-list.component.ts +++ b/apps/concierge/src/app/parking/parking-bookings-list.component.ts @@ -48,6 +48,11 @@ interface ParkingBookingExtensionColumn { size?: string; } +interface ParkingSpaceRestrictionOption { + id: string | number; + name: string; +} + interface ParkingBookingColumnTemplates { state_template: TemplateRef; type_template: TemplateRef; @@ -58,6 +63,9 @@ interface ParkingBookingColumnTemplates { plate_template: TemplateRef; notes_template: TemplateRef; status_template: TemplateRef; + requested_at_template: TemplateRef; + user_groups_template: TemplateRef; + allocation_group_template: TemplateRef; action_template: TemplateRef; status_busy_label: string; type_label: string; @@ -68,6 +76,8 @@ interface ParkingBookingColumnTemplates { plate_number_label: string; notes_label: string; status_label: string; + user_groups_label: string; + allocation_group_label: string; } @Component({ @@ -116,7 +126,7 @@ interface ParkingBookingColumnTemplates { [row_key]="rowKey" [can_select]="canSelect" [(selected)]="selected" - class="block min-w-304 text-sm" + class="block min-w-372 text-sm" [data]="filtered_events()" [columns]=" bookingColumns({ @@ -129,6 +139,9 @@ interface ParkingBookingColumnTemplates { plate_template, notes_template, status_template, + requested_at_template, + user_groups_template, + allocation_group_template, action_template, status_busy_label: 'COMMON.STATUS_BUSY' | translate, type_label: @@ -145,6 +158,11 @@ interface ParkingBookingColumnTemplates { 'BOOKINGS.PARKING_PLATE_NUMBER' | translate, notes_label: 'FORM.NOTES' | translate, status_label: 'COMMON.STATUS' | translate, + user_groups_label: + 'APP.CONCIERGE.PARKING_USER_GROUPS' | translate, + allocation_group_label: + 'APP.CONCIERGE.PARKING_ALLOCATION_GROUP' + | translate, }) " [filter]="options().search" @@ -246,8 +264,9 @@ interface ParkingBookingColumnTemplates { } } - +
+ @let id = row.asset_id; @if (id && !isRequestId(id)) { {{ (id | parkingSpace | async)?.identifier || id }} } @else { @@ -496,6 +515,30 @@ interface ParkingBookingColumnTemplates { } + +
+ @if (row.created_at) { + {{ + row.created_at + | date: 'MMM d, ' + time_format : timezone + }} + } @else { + + {{ 'COMMON.EMPTY' | translate }} + + } +
+
+ +
+ {{ row.user_groups.join(', ') }} +
+
+ +
+ {{ row.parking_group }} +
+
} + @if (matchedUserGroups(booking); as groups) { +
+ {{ + 'APP.CONCIERGE.PARKING_USER_GROUPS' + | translate + }}: + {{ groups }} +
+ } + @if (allocationGroup(booking); as group) { +
+ {{ + 'APP.CONCIERGE.PARKING_ALLOCATION_GROUP' + | translate + }}: + {{ group }} +
+ }
+ +
+ {{ request_type_label(request_type(row)) | translate }} +
+
+ +
+ @if (request_submitted_at(row)) { + {{ + request_submitted_at(row) + | date: 'MMM d, ' + time_format + }} + } @else { + {{ 'COMMON.EMPTY' | translate }} + } +
+
{{ row.user_name || row.user_email }}
@@ -311,6 +343,18 @@ import {
+ @if (!hide_assign_space) { + +
+
+ `, + styles: [ + ` + .bg-grad { + background: linear-gradient( + 105deg, + var(--brand-100) 0%, + var(--brand-200) 100% + ); + } + `, + ], + imports: [IconComponent], +}) +export class DeskFlowAutoAssignComponent { + private _booking_form = inject(BookingFormService); + private _dialog = inject(MatDialog); + private _router = inject(Router); + + public get model() { + return this._booking_form.model; + } + + /** Error to show in place of the modal, when the form is not ready */ + public readonly error = input(''); + + public readonly autoAssignDesk = () => { + if (this.error()) return notifyError(this.error()); + // Get current form values for date and duration + const { date, duration } = this.model(); + + // Open the auto-assigned desk modal + const dialog_ref = this._dialog.open(AutoAssignedDeskModalComponent, { + maxWidth: '100vw', + maxHeight: '100vh', + panelClass: 'auto-assigned-desk-modal', + }); + dialog_ref.componentInstance.show_close.set(true); + + // Pass date and duration if they're set + if (date) { + dialog_ref.componentInstance.date.set(date); + } + if (duration) { + dialog_ref.componentInstance.duration.set(duration); + } + + dialog_ref.afterClosed().subscribe((confirmed) => { + if (confirmed) { + this._booking_form.setView('success'); + } + }); + }; +} diff --git a/apps/workplace/src/app/book/desk-flow-new/desk-flow-details.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow-details.component.ts new file mode 100644 index 0000000000..fca64550b1 --- /dev/null +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow-details.component.ts @@ -0,0 +1,509 @@ +import { + Component, + computed, + effect, + inject, + model, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FormField } from '@angular/forms/signals'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { AssetListFieldComponent } from '@placeos/assets'; +import { + BookingFlowOptions, + BookingFormService, + requiresLocker, +} from '@placeos/bookings'; +import { + BookingRecurrence, + currentUser, + EMPTY_USER, + OrganisationService, + settingSignal, + SettingsService, +} from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { + DateFieldComponent, + DurationFieldComponent, + RecurrenceFieldComponent, + TimeFieldComponent, + UserListFieldComponent, + UserSearchFieldComponent, +} from '@placeos/form-fields'; +import { FindAvailabilityModalComponent } from '@placeos/users'; +import { addDays, endOfDay } from 'date-fns'; + +/** Who the desk booking is for: the current user, a group or one other person */ +export type DeskFormType = 'single' | 'group' | 'other'; + +@Component({ + selector: 'desk-flow-details', + template: ` +
+ @if (has_form_types()) { + +
+ + + +
+ {{ + form_type_config()[active_form()].icon + }} + {{ + form_type_config()[active_form()].label + }} +
+
+ +
+ person + Single +
+
+ @if (allow_groups()) { + +
+ group_add + Group +
+
+ } + @if (can_book_for_others()) { + +
+ person_add + Book for other +
+
+ } +
+
+
+ + + } +
+ @if (active_form() === 'other') { +
+ + +
+ } + @if (!hide_title()) { +
+ + + + {{ + 'FORM.TITLE_REQUIRED' | translate + }} + +
+ } + +
+
+ + + @if (allow_all_day()) { + + {{ 'COMMON.ALL_DAY' | translate }} + + } +
+ @if (!form_value().all_day) { +
+ + +
+
+ + +
+ } +
+ @if (can_recurr()) { +
+ + + @if (model().id) { + + {{ 'FORM.UPDATE_FUTURE' | translate }} + + } +
+ } + @if (can_book_lockers()) { +
+ + {{ 'BOOKINGS.DESK_REQUIRE_LOCKER' | translate }} + +
+ } + @if (active_form() === 'group') { +
+ + + + +

+ info + {{ + 'BOOKINGS.DESK_GROUP_MEMBERS_INFO' | translate + }} +

+
+ } + @if (has_assets() && active_form() !== 'group') { +
+ + +
+ } +
+
+ `, + styles: [``], + imports: [ + MatRippleModule, + MatFormFieldModule, + MatInputModule, + MatCheckboxModule, + MatSelectModule, + DateFieldComponent, + DurationFieldComponent, + TimeFieldComponent, + FormsModule, + FormField, + TranslatePipe, + IconComponent, + RecurrenceFieldComponent, + UserSearchFieldComponent, + UserListFieldComponent, + AssetListFieldComponent, + ], +}) +export class DeskFlowDetailsComponent { + private _booking_form = inject(BookingFormService); + private _org = inject(OrganisationService); + private _settings = inject(SettingsService); + private _dialog = inject(MatDialog); + + /** Selected booking type. The parent flow reads it when it confirms */ + public readonly active_form = model('single'); + public readonly form_type_config = signal({ + single: { icon: 'person', label: 'Single' }, + group: { icon: 'group_add', label: 'Group' }, + other: { icon: 'person_add', label: 'Book for other' }, + }); + public readonly form_value = this._booking_form.model; + public readonly requires_locker = computed(() => + requiresLocker(this.form_value().secondary_resource), + ); + public readonly options = this._booking_form.options; + + private _options_sync = effect(() => { + const options = this.options(); + if (options?.group && this.active_form() !== 'group') { + this.active_form.set('group'); + } + }); + + public readonly hide_title = settingSignal('desks.hide_title', false); + public readonly allow_groups = settingSignal( + 'desks.allow_groups', + false, + ); + public readonly can_book_for_others = computed( + () => + !!settingSignal('bookings.can_book_for_others')() || + !!settingSignal('desks.can_book_for_others')(), + ); + /** Whether the user can select a booking type other than `single` */ + public readonly has_form_types = computed( + () => this.allow_groups() || this.can_book_for_others(), + ); + public readonly has_assets = computed( + () => + !!settingSignal('bookings.has_assets')() || + !!settingSignal('desks.has_assets')(), + ); + public readonly bookable_hours = computed< + { start: number; end: number } | undefined + >( + () => + settingSignal('desks.bookable_hours')() || + settingSignal('bookings.bookable_hours')() || + undefined, + ); + public readonly available_days = settingSignal( + 'desks.available_period', + 90, + ); + + public readonly end_date = computed(() => { + return endOfDay(addDays(Date.now(), this.available_days())).valueOf(); + }); + + public get form() { + return this._booking_form.form; + } + + public get model() { + return this._booking_form.model; + } + public readonly duration_step = computed( + () => + settingSignal('desks.duration_step')() || + settingSignal('bookings.duration_step', 15)(), + ); + + public readonly min_duration = computed( + () => + settingSignal('desks.min_duration')() || + settingSignal('bookings.min_duration', 30)(), + ); + + public readonly max_duration = computed( + () => + settingSignal('desks.max_duration')() || + settingSignal('bookings.max_duration', 8 * 60)(), + ); + public readonly custom_duration_options = computed( + () => + settingSignal('desks.custom_duration_options')() || + settingSignal('bookings.custom_duration_options', [] as number[])(), + ); + public readonly can_book_lockers = settingSignal( + 'desks.can_book_lockers', + false, + ); + public readonly can_recurr = settingSignal('desks.allow_recurrence', false); + public readonly allow_time_changes = computed( + () => settingSignal('desks.allow_time_changes')() !== false, + ); + public readonly allow_all_day = computed( + () => + this.allow_time_changes() && + (settingSignal('desks.allow_all_day')() || + settingSignal('bookings.allow_all_day')()), + ); + public readonly use_24hr = settingSignal('use_24_hour_time', false); + + /** Whether the desk is allocated on confirm and not selected by the user */ + public get auto_allocation() { + return this._booking_form.auto_allocation; + } + + public get timezone() { + return this._settings.get('app.bookings.use_building_timezone') || + this._settings.get('app.desks.use_building_timezone') + ? this._org.building.timezone + : ''; + } + + public setActiveForm(form: DeskFormType) { + this.active_form.set(form); + this._booking_form.setOptions({ group: form === 'group' }); + if (form === 'single') { + this.model.update((m) => ({ + ...m, + user: currentUser(), + attendees: [], + })); + } else if (form === 'group') { + this.model.update((m) => ({ ...m, user: currentUser() })); + } else { + // A `null` user goes back to the form default, which can be the + // current user. The empty user keeps the field clear. + this.model.update((m) => ({ + ...m, + user: EMPTY_USER, + attendees: [], + })); + } + } + + /** Apply a user change from a control that has no `[formField]` binding. */ + public readonly setUserValues: BookingFormService['setUserValues'] = ( + value, + ) => this._booking_form.setUserValues(value); + + public setOptions(options: Partial) { + this._booking_form.setOptions(options); + } + + public onRecurrenceChange(recurrence: BookingRecurrence) { + this.setUserValues(recurrence); + } + + public onFirstInstanceChange(date: number) { + this.setUserValues({ date }); + } + + public findAvailableTime() { + const { date, duration } = this.model(); + const members = this.options()?.members ?? []; + const ref = this._dialog.open(FindAvailabilityModalComponent, { + data: { + users: members, + host: currentUser(), + date, + duration, + }, + }); + ref.afterClosed().subscribe((result) => { + if (!result) return; + this.model.update((m) => ({ + ...m, + date: ref.componentInstance.date(), + duration: ref.componentInstance.duration(), + })); + this.setOptions({ members: ref.componentInstance.users() }); + }); + } +} diff --git a/apps/workplace/src/app/book/desk-flow-new/desk-flow-select-list.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow-select-list.component.ts new file mode 100644 index 0000000000..0813b11e72 --- /dev/null +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow-select-list.component.ts @@ -0,0 +1,342 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + effect, + inject, + input, + model, + output, + signal, +} from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { BookingFormService } from '@placeos/bookings'; +import { + SETTING_KEYS, + settingSignal, + SettingsService, + Space, +} from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; + +@Component({ + selector: 'desk-flow-select-list', + template: ` + @if (!loading()) { + @if (available_items()?.length) { +
+ {{ + 'COMMON.RESULTS_COUNT' + | translate + : { count: available_items().length } + : available_items().length + }} +
+
    + @for ( + item of available_items() + | slice + : active_page() * page_size() + : active_page() * page_size() + page_size(); + track item.id + ) { +
  • +
    + +
    + + @if (item.approval) { +
    + {{ 'COMMON.APPROVAL_REQUIRED' | translate }} +
    + } +
  • + } +
+ @if (available_items().length > page_size()) { +
+
+ @let range = active_range(); + {{ range[0] }} - {{ range[1] }} +
+
+
+ Page {{ active_page() + 1 }} of {{ max_pages() }} +
+ + +
+ } + } @else { +
+

+ {{ 'BOOKINGS.DESK_LIST_EMPTY' | translate }} +

+
+ } + } @else { +
+ +

+ {{ 'BOOKINGS.DESK_LIST_LOADING' | translate }} + +

+
+ } + `, + styles: [ + ` + :host { + display: flex; + flex-direction: column; + min-height: 100%; + } + `, + ], + imports: [ + CommonModule, + TranslatePipe, + MatProgressSpinnerModule, + IconComponent, + MatRippleModule, + AuthenticatedImageDirective, + ], +}) +export class DeskFlowSelectListComponent { + private _booking_form = inject(BookingFormService); + private _settings = inject(SettingsService); + + public readonly selected_items = model([]); + public readonly promote_selected = input(false); + public readonly item_selected = output(); + + public readonly loading = this._booking_form.loading; + + private readonly _available_items = this._booking_form.available_resources; + + private readonly form_value = this._booking_form.model; + + private readonly _promote_on_list_change = signal(false); + + // Keep the active desk visible and first even while availability results are + // loading or changing underneath the current selection. + public readonly available_items = computed(() => { + const available = this._available_items(); + const form = this.form_value(); + const resources = form.resources || []; + + if ( + resources.length > 0 && + (this.promote_selected() || this._promote_on_list_change()) + ) { + const selected_ids = resources.map((r) => r.id); + const selected_resources = resources.map( + (resource) => + available.find((item) => item.id === resource.id) || resource, + ); + const remaining_resources = available.filter( + (item) => !selected_ids.includes(item.id), + ); + + return [...selected_resources, ...remaining_resources]; + } + + if (resources.length > 0) { + const existing_ids = available.map((r) => r.id); + const missing_resources = resources.filter( + (r) => !existing_ids.includes(r.id), + ); + + if (missing_resources.length > 0) { + return [...missing_resources, ...available]; + } + } + + return available; + }); + public readonly page = signal(0); + public readonly page_size = signal(10); + public readonly max_pages = computed(() => + Math.ceil(this.available_items().length / this.page_size()), + ); + /** Selected page, limited to the last page of the current desk list */ + public readonly active_page = computed(() => + Math.max(0, Math.min(this.page(), this.max_pages() - 1)), + ); + public readonly active_range = computed(() => [ + this.active_page() * this.page_size() + 1, + Math.min( + this.active_page() * this.page_size() + this.page_size(), + this.available_items()?.length, + ), + ]); + + private _last_auto_page_key = ''; + private _last_available_key = ''; + private _last_selected_key = ''; + + constructor() { + effect(() => { + const available_key = this._available_items() + .map((item) => item.id) + .join('|'); + const selected_key = this.selected_items().join('|'); + const selected_changed = selected_key !== this._last_selected_key; + const available_changed = available_key !== this._last_available_key; + + if (!selected_key || selected_changed) { + this._promote_on_list_change.set(false); + } else if (available_changed && this._last_available_key) { + this._promote_on_list_change.set(true); + } + + this._last_available_key = available_key; + this._last_selected_key = selected_key; + }); + + effect(() => { + const selected_id = this.selected_items()?.[0]; + if (!selected_id) return; + const merged_items = this.available_items(); + const selected_index = merged_items.findIndex( + (item) => item.id === selected_id, + ); + if (selected_index < 0) return; + const target_page = Math.floor(selected_index / this.page_size()); + const page_key = `${selected_id}:${selected_index}:${this.page_size()}`; + if (page_key === this._last_auto_page_key) return; + this._last_auto_page_key = page_key; + this.page.set(target_page); + }); + } + + public readonly favourites = settingSignal( + SETTING_KEYS.FAVORITE_DESKS, + [], + true, + ); + + public toggleFavourite(item: Space) { + const existing = this.favourites(); + const updated = existing.find((id) => item.id === id) + ? existing.filter((id) => id !== item.id) + : [...existing, item.id]; + + // Optimistically update UI + this.favourites.set(updated); + + // Save to settings in background + this._settings.saveUserSetting(SETTING_KEYS.FAVORITE_DESKS, updated); + } +} diff --git a/apps/workplace/src/app/book/desk-flow-new/desk-flow-select-map.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow-select-map.component.ts new file mode 100644 index 0000000000..a336c5c40a --- /dev/null +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow-select-map.component.ts @@ -0,0 +1,373 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + debounced, + effect, + inject, + Injector, + input, + OnInit, + output, + resource, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { + BookingAsset, + BookingFormService, + queryAllBookings, +} from '@placeos/bookings'; +import { + AsyncHandler, + Booking, + BuildingLevel, + OrganisationService, + SettingsService, + Space, +} from '@placeos/common'; +import { + BuildingPipe, + IconComponent, + InteractiveMapComponent, + TranslatePipe, +} from '@placeos/components'; +import { DEFAULT_COLOURS } from '@placeos/explore'; +import { getUnixTime } from 'date-fns'; +import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; +import { + DeskInfoData, + ExploreDeskInfoComponent, +} from 'libs/explore/src/lib/explore-desk-info.component'; + +@Component({ + selector: 'desk-flow-select-map', + template: ` + @if (levels()?.length) { +
+ + + @for (lvl of levels(); track lvl) { + +
+ @if (use_region()) { +
+ {{ + (lvl?.parent_id | building) + ?.display_name + }} + - +
+ } +
{{ lvl.display_name || lvl.name }}
+
+
+ } +
+
+
+ } +
+ +
+ @if (selected_desk()) { +
+
+ task_alt + @if (selected_desk().images?.length) { + + } @else { + + } +
+
+
+ {{ + selected_desk().display_name || + selected_desk().name || + selected_desk().id + }} +
+
+ place +

+ {{ + selected_desk().location || + selected_desk().zone?.display_name || + selected_desk().zone?.name + }} +

+
+
+
+ {{ 'COMMON.SELECTED' | translate }} +
+
+ } + `, + styles: [``], + imports: [ + CommonModule, + InteractiveMapComponent, + MatFormFieldModule, + MatSelectModule, + FormsModule, + IconComponent, + TranslatePipe, + BuildingPipe, + AuthenticatedImageDirective, + ], +}) +export class DeskFlowSelectMapComponent extends AsyncHandler implements OnInit { + private _booking_form = inject(BookingFormService); + private _org = inject(OrganisationService); + private _settings = inject(SettingsService); + private _injector = inject(Injector); + private readonly _use_region = this._settings.signal('use_region', false); + private readonly _show_users = this._settings.signal( + 'desks.show_users', + true, + ); + + public readonly selected_items = input([]); + public readonly active = input(undefined); + public readonly is_displayed = input(false); + public readonly item_selected = output(); + + public readonly zoom = signal(1); + public readonly center = signal({ x: 0.5, y: 0.5 }); + public readonly coordinates = signal(undefined); + + private _selectedItem = (s) => () => this.item_selected.emit(s); + public readonly setOptions = (o) => this._booking_form.setOptions(o); + public readonly level = signal(null); + public readonly available_resources = this._booking_form.available_resources; + public readonly form_value = this._booking_form.model; + + // Keep the active desk visible even if it falls outside the current + // availability result set while the form is loading or filters are updating. + private readonly current_available = computed(() => { + const available = this.available_resources(); + const form = this.form_value(); + const resources = form.resources || []; + + if (resources.length > 0) { + const existing_ids = available.map((r) => r.id); + const missing_resources = resources.filter( + (r) => !existing_ids.includes(r.id), + ); + + if (missing_resources.length > 0) { + return [...missing_resources, ...available]; + } + } + + return available; + }); + + public readonly map_url = computed(() => this.level()?.map_id || ''); + public readonly resource_list = this._booking_form.resources; + public readonly features = computed(() => + this.resource_list().map((space) => { + const map_id = this._mapId(space); + return { + track_id: `desk:hover:${map_id}`, + location: map_id, + content: ExploreDeskInfoComponent, + full_size: true, + no_scale: true, + data: { + id: space.id, + map_id, + name: space.display_name || space.name || space.id, + user: computed( + () => this._booked_users()[space.id]?.join(', ') || '', + ), + status: computed(() => this._deskStatus(space)), + bookings: signal([]), + } satisfies DeskInfoData, + z_index: 20, + }; + }), + ); + /** Level and time window used to find who booked each desk */ + private readonly _booking_query = computed(() => { + const zone = this.level()?.id; + const form = this.form_value(); + if (!this._show_users() || !zone || !(form?.date > 0)) return; + const { start, end } = this._booking_form.bookingWindow(form); + if (!(end > start)) return; + return { zone, start, end }; + }); + /** Booking query, debounced to coalesce rapid form changes */ + private readonly _booking_query_debounced = debounced( + this._booking_query, + 500, + { + equal: (a, b) => + a?.zone === b?.zone && + a?.start === b?.start && + a?.end === b?.end, + }, + ); + /** Desk bookings on the active level within the search window */ + private readonly _level_bookings = resource({ + params: () => this._booking_query_debounced.value(), + loader: ({ params: { zone, start, end } }) => + queryAllBookings({ + type: 'desk', + zones: zone, + period_start: getUnixTime(start), + period_end: getUnixTime(end), + limit: 200, + }), + }); + /** Names of the people who booked each desk, mapped by desk ID */ + private readonly _booked_users = computed(() => { + const users: Record = {}; + const bookings: Booking[] = this._level_bookings.value() ?? []; + for (const booking of bookings) { + if (['cancelled', 'declined'].includes(booking.status)) continue; + const name = booking.user_name || booking.booked_by_name; + if (!name) continue; + for (const id of booking.asset_ids) { + users[id] ??= []; + if (!users[id].includes(name)) users[id].push(name); + } + } + return users; + }); + public readonly use_region = this._use_region; + public readonly levels = computed(() => { + const region = this._org.active_region(); + const bld = this._org.active_building(); + const resources = this._booking_form.resources(); + const level_list = this.use_region() + ? this._org.levelsForRegion(region) + : this._org.levelsForBuilding(bld); + const level_ids = new Set( + resources + .filter((resource) => resource.bookable !== false) + .map((resource) => resource.zone?.id) + .filter((_) => _), + ); + return level_list + .filter( + (lvl) => + !lvl.tags.includes('parking') && level_ids.has(lvl.id), + ) + .sort( + (a, b) => + a.parent_id.localeCompare(b.parent_id) || + (a.display_name || '').localeCompare(b.display_name || ''), + ); + }); + + public readonly selected_desk = computed(() => { + const selected_ids = this.selected_items(); + if (!selected_ids?.length) return null; + const available = this.current_available(); + return available.find((desk) => selected_ids.includes(desk.id)) || null; + }); + public readonly actions = computed(() => + this.current_available().map((space) => ({ + id: this._mapId(space), + action: ['touchend', 'mouseup'], + callback: this._selectedItem(space), + })), + ); + + public readonly styles = computed(() => { + const spaces = this.resource_list(); + return spaces.reduce((styles, space) => { + const colours = this._settings.get('app.explore.colors') || {}; + const status = this._deskStatus(space); + styles[`#${this._mapId(space)}`] = { + fill: + colours[`space-${status}`] || + colours[`${status}`] || + DEFAULT_COLOURS[`${status}`], + }; + return styles; + }, {}); + }); + + private _mapId(space: Pick) { + return space.map_id || space.id; + } + + private _deskStatus(space: Pick) { + return this.current_available().some((item) => item.id === space.id) + ? this.selected_items().includes(space.id) + ? 'pending' + : 'free' + : 'busy'; + } + + public ngOnInit() { + const ref = effect( + () => { + const details = this._booking_form.options(); + const level = this._org.levelWithID( + details.zones || [details.zone_id], + ); + if (level) this.level.set(level); + }, + { injector: this._injector }, + ); + this.subscription('levels_update', () => ref.destroy()); + } + + public setLevel(level: BuildingLevel) { + this.setOptions({ zones: [level?.id], zone_id: level?.id }); + const bld = this._org.buildings.find((_) => _.id === level?.parent_id); + if (bld) { + const [latitude, longitude] = (level.location || bld.location) + .split(',') + .map((_) => parseFloat(_)); + this.coordinates.set({ latitude, longitude }); + } + this.level.set(level); + } + + public setZoom(new_zoom: number) { + this.zoom.set(Math.max(0.5, Math.min(10, new_zoom))); + } + + public resetMap() { + this.zoom.set(1); + this.center.set({ x: 0.5, y: 0.5 }); + } +} diff --git a/apps/workplace/src/app/book/desk-flow-new/desk-flow-select.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow-select.component.ts new file mode 100644 index 0000000000..970a0cf828 --- /dev/null +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow-select.component.ts @@ -0,0 +1,880 @@ +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + effect, + inject, + Injector, + OnInit, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FormField } from '@angular/forms/signals'; +import { MatRippleModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { BookingFormService } from '@placeos/bookings'; +import { + AsyncHandler, + flatten, + OrganisationService, + settingSignal, + SettingsService, + Space, + unique, +} from '@placeos/common'; +import { + BuildingPipe, + IconComponent, + LevelPipe, + SettingsToggleComponent, + TranslatePipe, +} from '@placeos/components'; +import { SpacesService } from '@placeos/events'; +import { + DateFieldComponent, + DurationFieldComponent, + TimeFieldComponent, +} from '@placeos/form-fields'; +import { addDays, endOfDay } from 'date-fns'; +import { DeskFlowSelectListComponent } from './desk-flow-select-list.component'; +import { DeskFlowSelectMapComponent } from './desk-flow-select-map.component'; + +@Component({ + selector: 'desk-flow-select', + template: ` +
+
+
+ desk +
+ {{ 'BOOKINGS.DESK_SELECT_HEADER' | translate }} +
+
+ +
+ + @if (filters_open()) { +
+ } +
+ + + +
+
+
+ @let feature_list = options()?.features || []; + @let zones = options()?.zones || []; + @let event = model(); + @let zone = (zones | level) || (zones | building); + @let location = + zone?.display_name || zone?.name || ''; + @if (feature_list.length > 1) { + + } + @if (location) { +
+ {{ location }} +
+ } +
+ {{ event.date | date: 'mediumDate' }} +
+
+ @if (!event.all_day) { + {{ event.date | date: time_format }} — + {{ event.date_end | date: time_format }} + } + @if (event.all_day) { + {{ 'COMMON.ALL_DAY' | translate }} + } +
+ @for (feat of feature_list; track feat) { +
+

{{ feat }}

+ +
+ } +
+
+ + +
+
+
+ @if (view() === 'map') { + + } @else { + + } +
+
+
+ +
+
+

+ {{ 'COMMON.FILTERS' | translate }} +

+ +
+
+ + @if (use_region() && regions()?.length) { + + + @for (reg of regions(); track reg) { + + {{ reg.display_name || reg.name }} + + } + + + } + @if (!use_region() && buildings()?.length > 1) { + + + @for (bld of buildings(); track bld) { + + {{ bld.display_name || bld.name }} + + } + + + } + @if (view() === 'list') { + + + @for (lvl of levels(); track lvl) { + +
+ @if (use_region()) { +
+ {{ + ( + lvl?.parent_id + | building + )?.display_name + }} + + - + +
+ } +
+ {{ + lvl.display_name || lvl.name + }} +
+
+
+ } +
+
+ } + + + {{ + 'COMMON.ALL_DAY' | translate + }} + @if (!field('all_day')) { + +
+ + +
+ } + @if (view() === 'list') { + {{ + 'COMMON.FAVOURITES_ONLY' | translate + }} +

+ {{ 'BOOKINGS.FACILITIES' | translate }} +

+
+ @for (feat of features(); track feat) { + @if (!hide_features().includes(feat)) { + + } + } +
+ } +
+
+
+ `, + styles: [ + ` + .gradient { + background: linear-gradient( + 105deg, + var(--base-200) 0%, + var(--base-200) 50%, + var(--base-100) 100% + ); + } + .filters-panel-mobile { + border-radius: 1rem 1rem 0 0; + max-height: 70vh; + } + + [filter-item] { + display: flex; + align-items: center; + padding: 0 1rem; + min-height: 2rem; + height: 2rem; + font-size: 0.875rem; + border: 1px solid var(--base-300); + border-radius: 1.25rem; + margin: 0.25rem; + max-width: 100%; + text-align: center; + } + + [filter-item]:hover { + background: rgba(0, 0, 0, 0.1); + } + + [filter-item] > * + * { + margin-left: 0.5rem; + } + `, + ], + imports: [ + CdkScrollable, + CommonModule, + MatRippleModule, + IconComponent, + MatFormFieldModule, + MatSelectModule, + DateFieldComponent, + TimeFieldComponent, + DurationFieldComponent, + FormField, + FormsModule, + SettingsToggleComponent, + TranslatePipe, + BuildingPipe, + LevelPipe, + DeskFlowSelectListComponent, + DeskFlowSelectMapComponent, + ], +}) +export class DeskFlowSelectComponent extends AsyncHandler implements OnInit { + private _booking_form = inject(BookingFormService); + private _org = inject(OrganisationService); + private _settings = inject(SettingsService); + private _spaces = inject(SpacesService); + private _injector = inject(Injector); + + public readonly use_24hr = settingSignal('use_24_hour_time', false); + public readonly use_region = settingSignal('use_region', false); + public readonly available_days = settingSignal( + 'desks.available_period', + 90, + ); + public readonly end_date = computed(() => + endOfDay(addDays(Date.now(), this.available_days())).valueOf(), + ); + public readonly duration_step = computed( + () => + settingSignal('desks.duration_step')() || + settingSignal('bookings.duration_step', 15)(), + ); + + public readonly min_duration = computed( + () => + settingSignal('desks.min_duration')() || + settingSignal('bookings.min_duration', 30)(), + ); + + public readonly max_duration = computed( + () => + settingSignal('desks.max_duration')() || + settingSignal('bookings.max_duration', 8 * 60)(), + ); + public readonly custom_duration_options = computed( + () => + settingSignal('desks.custom_duration_options')() || + settingSignal('bookings.custom_duration_options', [] as number[])(), + ); + public readonly bookable_hours = computed< + { start: number; end: number } | undefined + >( + () => + settingSignal('desks.bookable_hours')() || + settingSignal('bookings.bookable_hours')() || + undefined, + ); + public readonly feature_display = settingSignal>( + 'desks.feature_decriptions', + {}, + ); + public readonly hide_features = settingSignal( + 'desks.hide_features', + [], + ); + public readonly form = this._booking_form.form; + public readonly model = this._booking_form.model; + public readonly selected = signal([]); + public readonly promote_selected = signal(false); + public readonly view = signal<'map' | 'list'>('list'); + public readonly filters_open = signal(false); + + private readonly form_value = this._booking_form.model; + + public readonly has_item = computed( + () => + !!this.form_value()?.resources && + this.form_value()?.resources.length > 0, + ); + + public readonly options = this._booking_form.options; + public readonly options_value = this._booking_form.options; + + public readonly building = this._org.active_building; + public readonly buildings = this._org.active_buildings; + public readonly region = this._org.active_region; + public readonly regions = this._org.region_list; + + public readonly setBuilding = (bld) => (this._org.building = bld); + public readonly setRegion = (region) => (this._org.region = region); + public readonly setOptions = (o) => this._booking_form.setOptions(o); + public readonly loading = this._booking_form.loading; + + public readonly active = signal(''); + private _manual_selection = false; + private readonly bookable_resources = this._booking_form.resources; + + constructor() { + super(); + effect(() => { + const selected_ids = (this.form_value()?.resources || []).map( + ({ id }) => id, + ); + this.selected.set(selected_ids); + this.promote_selected.set( + !!selected_ids.length && !this._manual_selection, + ); + this._manual_selection = false; + }); + } + + public readonly levels = computed(() => { + const region = this._org.active_region(); + const bld = this._org.active_building(); + const resources = this._booking_form.resources(); + const level_list = this.use_region() + ? this._org.levelsForRegion(region) + : this._org.levelsForBuilding(bld); + const level_ids = new Set( + resources + .filter((resource) => resource.bookable !== false) + .map((resource) => resource.zone?.id) + .filter((_) => _), + ); + const viewable_levels = level_list.filter( + (lvl) => !lvl.tags.includes('parking') && level_ids.has(lvl.id), + ); + return viewable_levels.sort( + (a, b) => + a.parent_id.localeCompare(b.parent_id) || + (a.display_name || '').localeCompare(b.display_name || ''), + ); + }); + + public readonly features = computed(() => + unique( + flatten( + this._booking_form.resources().map((_) => _.features), + ).filter((_) => _.trim()), + ), + ); + + public get timezone() { + return this._settings.get('app.bookings.use_building_timezone') + ? this._org.building.timezone + : ''; + } + + public get time_format() { + return this.use_24hr() ? 'HH:mm' : 'h:mm a'; + } + + public ngOnInit() { + let previous_building_id = this._org.active_building()?.id; + this.subscription( + 'clear_selected_desk_on_building_change', + this._effect(() => { + const bld = this._org.active_building(); + const selected_resources = this.field('resources') || []; + const selected_for_building = + !!selected_resources.length && + selected_resources.every( + (resource) => resource.zone?.parent_id === bld?.id, + ); + if ( + previous_building_id && + bld?.id && + previous_building_id !== bld.id && + !selected_for_building + ) { + this.clearSelectedDesk(); + } + previous_building_id = bld?.id; + }), + ); + + const resources = this.field('resources') || []; + const selected_ids = resources.map(({ id }) => id); + + // Handle edit mode: if form has an id and asset_id but no resources populated yet + if (this.field('id') && !resources.length) { + const asset_id = this.field('asset_id'); + if (asset_id) { + // Set the selected ID immediately for UI feedback + selected_ids.push(asset_id); + + // React to available resources to populate the full resource + // object once the resources are loaded + this.subscription( + 'load_existing_resource', + this._effect(() => { + const available = + this._booking_form.available_resources(); + if (!available.length) return; + + const matching_resource = available.find( + (r) => r.id === asset_id, + ); + + // Only patch if we found the resource and resources array is still empty + if ( + matching_resource && + !this.field('resources')?.length + ) { + this.model.update((m) => ({ + ...m, + resources: [matching_resource], + asset_id: matching_resource.id, + })); + this.selected.set([matching_resource.id]); + } + }), + ); + } + } + + this.selected.set(selected_ids); + } + + /** Create an effect and return a teardown compatible with subscription() */ + private _effect(fn: () => void) { + const ref = effect(fn, { injector: this._injector }); + return () => ref.destroy(); + } + + public field(name: string) { + return this.model()?.[name]; + } + + public clearSelectedDesk() { + this.model.update((m) => ({ ...m, resources: [], asset_id: '' })); + this.selected.set([]); + } + + public async toggleFeature(feat: string, state: boolean) { + const { features } = this.options_value(); + const new_list = (features || []).filter((_) => feat !== _); + if (state) new_list.push(feat); + this._booking_form.setOptions({ features: new_list }); + } + + public removeFeature(feat: string) { + const { features } = this.options_value(); + const new_list = (features || []).filter((_) => feat !== _); + this._booking_form.setOptions({ features: new_list }); + } + + public removeAllFeatures() { + this._booking_form.setOptions({ features: [] }); + } + + public setMapView() { + this.view.set('map'); + const level_list = this.use_region() + ? this._org.levelsForRegion(this._org.region) + : this._org.levelsForBuilding(this._org.building); + const level_ids = new Set( + this.bookable_resources() + .filter((resource) => resource.bookable !== false) + .map((resource) => resource.zone?.id) + .filter((_) => _), + ); + const viewable_levels = level_list + .filter( + (lvl) => !lvl.tags.includes('parking') && level_ids.has(lvl.id), + ) + .sort( + (a, b) => + a.parent_id.localeCompare(b.parent_id) || + (a.display_name || '').localeCompare(b.display_name || ''), + ); + if (!viewable_levels.length) return; + // The zone filters are hidden on the map, so apply the user's selected + // level as the map's active level. Prefer a level they already picked in + // the list filters, otherwise default to the first viewable level. + const current_zones = this.options_value()?.zones || []; + const active_level = + viewable_levels.find((lvl) => current_zones.includes(lvl.id)) || + viewable_levels[0]; + this.setOptions({ zones: [active_level.id], zone_id: active_level.id }); + } + + public toggleDesk(space: Space) { + const resources = this.field('resources') || []; + this._manual_selection = true; + if (this._settings.get('app.desks.allow_multiple')) { + const new_resources = resources.find(({ id }) => id === space.id) + ? resources.filter(({ id }) => id !== space.id) + : [...resources, space]; + this.model.update((m) => ({ + ...m, + resources: new_resources, + asset_id: new_resources[0]?.id || '', + })); + this.selected.set(new_resources.map(({ id }) => id)); + } else { + this.model.update((m) => ({ + ...m, + resources: [space], + asset_id: space.id, + })); + this.selected.set([space.id]); + // Close filters on mobile after selecting a space + this.filters_open.set(false); + } + } +} diff --git a/apps/workplace/src/app/book/desk-flow/desk-flow-success.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow-success.component.ts similarity index 100% rename from apps/workplace/src/app/book/desk-flow/desk-flow-success.component.ts rename to apps/workplace/src/app/book/desk-flow-new/desk-flow-success.component.ts diff --git a/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts new file mode 100644 index 0000000000..b3794c21fe --- /dev/null +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts @@ -0,0 +1,462 @@ +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { + Component, + computed, + effect, + inject, + OnInit, + signal, +} from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + BookingAsset, + BookingFlowView, + BookingFormService, + findNearbyFeature, +} from '@placeos/bookings'; +import { + AsyncHandler, + currentUser, + flatten, + getInvalidSignalFields, + i18n, + isEmptyUser, + notifyError, + notifySuccess, + OrganisationService, + User, +} from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { SpacePipe } from '@placeos/events'; +import { listChildMetadata } from '@placeos/ts-client'; +import { set } from 'date-fns'; +import { DeskFlowAutoAssignComponent } from './desk-flow-auto-assign.component'; +import { + DeskFlowDetailsComponent, + DeskFormType, +} from './desk-flow-details.component'; +import { DeskFlowSelectComponent } from './desk-flow-select.component'; +import { NewDeskFlowSuccessComponent } from './desk-flow-success.component'; + +@Component({ + selector: 'desk-flow-new', + template: ` + @if (view() !== 'success') { +
+
+ @if (!is_edit_mode()) { + + } +
+
+ info +
+ {{ 'BOOKINGS.DESK_DETAILS_HEADER' | translate }} +
+
+ + @if (auto_allocation) { +

+ {{ + 'BOOKINGS.DESK_AUTO_ALLOCATION_INFO' + | translate + }} +

+ } @else { + + } +
+
+
+ +
+
+
+ } @else { + + } + `, + styles: [ + ` + :host { + width: 100%; + height: 100%; + } + + .gradient { + background: linear-gradient( + 105deg, + var(--base-200) 0%, + var(--base-200) 50%, + var(--base-100) 100% + ); + } + `, + ], + imports: [ + CdkScrollable, + IconComponent, + TranslatePipe, + MatRippleModule, + DeskFlowAutoAssignComponent, + DeskFlowDetailsComponent, + DeskFlowSelectComponent, + NewDeskFlowSuccessComponent, + ], +}) +export class DeskFlowNewComponent extends AsyncHandler implements OnInit { + private _booking_form = inject(BookingFormService); + private _org = inject(OrganisationService); + private _router = inject(Router); + private _route = inject(ActivatedRoute); + private _space_pipe: SpacePipe = new SpacePipe(this._org); + + public readonly view = this._booking_form.view; + public readonly loading = signal(false); + public readonly options = this._booking_form.options; + + public readonly form_value = this._booking_form.model; + + public readonly has_title = computed( + () => !!this.form_value()?.title?.trim(), + ); + + public readonly has_item = computed( + () => + !!this.form_value()?.resources && + this.form_value()?.resources.length > 0, + ); + + public readonly is_edit_mode = computed(() => !!this.form_value()?.id); + /** Booking type selected in the details step */ + public readonly active_form = signal('single'); + // A saved booking resets the form options. Reset the booking type with + // them, as this component stays alive for the next booking. + private readonly _reset_form_type = effect(() => { + if (this.view() === 'success') this.active_form.set('single'); + }); + /** Error for a "Book for other" booking that has no person selected */ + public readonly other_user_error = computed(() => + this.active_form() === 'other' && isEmptyUser(this.form_value()?.user) + ? i18n('FORM.INVALID_FIELDS', { + field_list: i18n('FORM.BOOK_FOR'), + }) + : '', + ); + + /** Whether the desk is allocated on confirm and not selected by the user */ + public get auto_allocation() { + return this._booking_form.auto_allocation; + } + + /** Stops the async waits of this component after it is destroyed */ + private _destroyed = false; + + public ngOnInit() { + const { id, booking_type } = this._booking_form.model(); + if (!id || booking_type !== 'desk') this._booking_form.newForm('desk'); + this._booking_form.model.update((m) => ({ + ...m, + booking_type: 'desk', + })); + this._booking_form.setOptions({ type: 'desk' }); + if (!this._booking_form.model().id) + this._booking_form.model.update((m) => ({ + ...m, + title: 'Booking', + })); + this._loadGroupMembers(); + this.subscription( + 'route.params', + this._route.paramMap.subscribe((param) => { + if (param.has('step')) + this._booking_form.setView( + param.get('step') as BookingFlowView, + ); + }), + ); + this.subscription( + 'route.query', + this._route.queryParamMap.subscribe(async (params) => { + if (params.has('nearby_space')) { + await this._initNearbyDeskBooking( + params.get('nearby_space'), + parseInt(params.get('date'), 10) || Date.now(), + ); + return; + } + if (!params.has('asset_id')) return; + const asset_id = params.get('asset_id'); + const form = this._booking_form.model(); + if ( + asset_id === form.asset_id && + (form.resources || []).some(({ id }) => id === asset_id) + ) { + return; + } + await this._waitForLoaded(); + if (!asset_id || this._destroyed) return; + const resource = await this._findDeskResource(asset_id); + if (!resource) return; + const building = resource.zone?.parent_id + ? this._org.find(resource.zone.parent_id) + : null; + if (building && building.id !== this._org.building?.id) { + this._org.building = building; + } + this._booking_form.setOptions({ + type: 'desk', + ...(resource.zone?.id ? { zones: [resource.zone.id] } : {}), + }); + this._booking_form.model.update((m) => ({ + ...m, + booking_type: 'desk', + resources: [resource], + asset_id: resource.id, + })); + }), + ); + } + + public override ngOnDestroy() { + super.ngOnDestroy(); + this._destroyed = true; + } + + /** + * Restore the group option and the members when the edited booking is + * part of a group, so that a save updates every member. + */ + private async _loadGroupMembers() { + const booking = this._booking_form.booking; + if (!this._booking_form.model().id || !booking?.id) return; + const is_group = + !!booking.parent_id || + !!booking.group || + !!booking.extension_data?.group_members?.length; + if (!is_group) return; + // With no members the save fails with an error. It does not save + // the booking of the organiser only. + const members = await this._booking_form + .loadGroupMembersForBooking(booking) + .catch((): User[] => []); + if (this._destroyed) return; + this._booking_form.setOptions({ group: true, members }); + } + + /** + * Resolve once the booking form has finished its current load. Stops + * after 10 seconds or when the component is destroyed. + */ + private async _waitForLoaded() { + for (let attempt = 0; attempt < 200; attempt++) { + if (this._destroyed || !this._booking_form.loading()) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + + private async _findDeskResource( + asset_id: string, + ): Promise { + const resource = await this._findDeskResourceFromStream(asset_id, 50); + if (resource) return resource; + + const building_resource = + await this._findDeskResourceFromBuildings(asset_id); + return ( + building_resource || + (await this._findDeskResourceFromStream(asset_id, 5000)) + ); + } + + private async _findDeskResourceFromStream( + asset_id: string, + wait_ms: number, + ): Promise { + const deadline = Date.now() + wait_ms; + do { + const resources = await this._booking_form.listResources(); + const resource = resources.find((item) => item.id === asset_id); + if (resource) return resource; + if (Date.now() >= deadline) break; + await new Promise((resolve) => setTimeout(resolve, 50)); + } while (Date.now() < deadline); + return null; + } + + private async _findDeskResourceFromBuildings( + asset_id: string, + ): Promise { + for (const building of this._org.buildings || []) { + const data = await listChildMetadata(building.id, { + name: 'desks', + }).catch(() => []); + const resources = flatten( + data.map((metadata) => + (metadata?.metadata?.desks?.details instanceof Array + ? metadata.metadata.desks.details + : [] + ).map((desk) => ({ + ...desk, + id: desk.id || desk.map_id, + zone: metadata.zone, + })), + ), + ); + const resource = resources.find((item) => item.id === asset_id); + if (resource) return resource; + } + return null; + } + + private async _initNearbyDeskBooking(space_id: string, event_date: number) { + const space = await this._space_pipe.transform(space_id); + const level = this._org.levelWithID(space?.zones); + this._booking_form.setOptions({ type: 'desk', zone_id: level?.id }); + this._booking_form.model.update((m) => ({ + ...m, + date: set(event_date, { hours: 8, minutes: 0 }).valueOf(), + duration: 10 * 60, + all_day: true, + booking_type: 'desk', + user: currentUser(), + })); + const resources = await this._booking_form.listAvailableResources(); + const bookable_desks = resources + .map((_) => _.map_id || _.id) + .filter((i) => i); + const nearby = await findNearbyFeature( + level.map_id, + space?.map_id, + bookable_desks, + ); + if (!nearby) + return notifyError(i18n('APP.WORKPLACE.MEETING_DESK_ERROR')); + const resource = resources.find( + (_) => _.map_id === nearby || _.id === nearby, + ); + if (!resource) + return notifyError(i18n('APP.WORKPLACE.MEETING_DESK_ERROR')); + this._booking_form.model.update((m) => ({ + ...m, + date: set(event_date, { hours: 8, minutes: 0 }).valueOf(), + duration: 10 * 60, + all_day: true, + booking_type: 'desk', + asset_id: resource.id, + asset_name: resource.name, + resources: [resource], + })); + } + + /** + * Save the booking. An edit of a group booking updates the booking of + * every member. The other bookings save as a new group or a single booking. + */ + private async _postBooking() { + if (!this.options()?.group) return this._booking_form.postForm(); + const booking = this._booking_form.booking; + if (!this._booking_form.model().id || !booking?.id) { + return this._booking_form.postFormForGroup(); + } + const siblings = await this._booking_form.loadGroupSiblings(booking); + return this._booking_form.editFormForGroup( + siblings.length ? siblings : [booking], + ); + } + + public async confirmBooking() { + if (this.other_user_error()) { + return notifyError(this.other_user_error()); + } + if (this.auto_allocation) { + try { + await this._booking_form.autoAllocateDesk(); + } catch (e) { + return notifyError( + typeof e === 'string' + ? e + : i18n('BOOKINGS.DESK_AVAILABLE_ERROR'), + ); + } + } + const { asset_id, resources } = this._booking_form.model(); + if (resources?.length && asset_id !== resources[0].id) { + this._booking_form.model.update((m) => ({ + ...m, + asset_id: resources[0].id, + })); + } + this._booking_form.form().markAsTouched(); + if (!this._booking_form.form().valid()) { + return notifyError( + i18n('FORM.INVALID_FIELDS', { + field_list: getInvalidSignalFields( + this._booking_form.form, + this._booking_form.model, + ) + .join(', ') + .replace('asset_id', i18n('RESOURCE.DESK')), + }), + ); + } + this.loading.set(true); + try { + await this._postBooking(); + notifySuccess(i18n('APP.CONCIERGE.DESKS_BOOKING_SUCCESS')); + this._router.navigate(['/book/desk/success']); + } catch (e) { + notifyError( + typeof e === 'string' + ? e + : i18n(`BOOKINGS.DESK_AVAILABLE_ERROR`), + ); + } finally { + this.loading.set(false); + } + } +} diff --git a/apps/workplace/src/app/book/desk-flow.component.ts b/apps/workplace/src/app/book/desk-flow.component.ts index b3d473613a..106a366b76 100644 --- a/apps/workplace/src/app/book/desk-flow.component.ts +++ b/apps/workplace/src/app/book/desk-flow.component.ts @@ -17,8 +17,8 @@ import { } from '@placeos/common'; import { SpacePipe } from '@placeos/events'; import { set } from 'date-fns'; +import { NewDeskFlowSuccessComponent } from './desk-flow-new/desk-flow-success.component'; import { NewDeskFlowFormComponent } from './desk-flow/desk-flow-form.component'; -import { NewDeskFlowSuccessComponent } from './desk-flow/desk-flow-success.component'; @Component({ selector: 'placeos-new-book-desk-flow', @@ -99,6 +99,7 @@ export class NewDeskFlowComponent extends AsyncHandler implements OnInit { } this._state.model.update((m) => ({ ...m, + asset_id: asset.id, resources: [ new Desk({ id: asset.id, diff --git a/apps/workplace/src/app/book/desk-flow/auto-assigned-desk-modal.component.ts b/apps/workplace/src/app/book/desk-flow/auto-assigned-desk-modal.component.ts new file mode 100644 index 0000000000..f63fa4d7a3 --- /dev/null +++ b/apps/workplace/src/app/book/desk-flow/auto-assigned-desk-modal.component.ts @@ -0,0 +1,510 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + inject, + model, + OnInit, + signal, +} from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { Router } from '@angular/router'; +import { + BookingAsset, + BookingFormService, + BookingFormValue, + findNearbyFeature, +} from '@placeos/bookings'; +import { + AsyncHandler, + currentUser, + Desk, + i18n, + notifyError, + OrganisationService, + SettingsService, +} from '@placeos/common'; +import { + IconComponent, + InteractiveMapComponent, + MapPinComponent, + TranslatePipe, +} from '@placeos/components'; +import { isBefore, startOfMinute } from 'date-fns'; + +@Component({ + selector: 'auto-assigned-desk-modal', + standalone: true, + template: ` + @if (show()) { +
+ +
+

+ {{ 'BOOKINGS.DESK_AUTO_ASSIGNED_TITLE' | translate }} +

+ @if (!loading()) { + @if (show_close()) { + + } + } +
+ + +
+ @if (loading()) { + +
+ +

+ {{ + (loading() == 'booking' + ? 'BOOKINGS.DESK_REQUESTING' + : 'BOOKINGS.DESK_LIST_LOADING' + ) | translate + }} +

+
+ } @else { + +
+
+ done +
+
+

+ {{ 'BOOKINGS.DESK_FOUND' | translate }} +

+

+ {{ + 'BOOKINGS.DESK_PERFECT_DESK_MESSAGE' + | translate + }} +

+
+
+ + +
+
+ chair +
+
+ {{ 'RESOURCE.DESK' | translate }} +
+
+ {{ + assigned_desk()?.name || + assigned_desk()?.id + }} +
+
+
+
+ layers +
+
+ {{ 'COMMON.FLOOR' | translate }} +
+
+ {{ level_name() }} +
+
+
+
+ place +
+
+ {{ + 'BOOKINGS.DESK_NEIGHBOURHOOD' + | translate + }} +
+
+ {{ location() }} +
+
+
+
+ + +
+ {{ 'BOOKINGS.DESK_LOCATION_ON_MAP' | translate }} +
+
+ @if (map_url()) { + + } @else { +
+
+ map +

+ {{ + 'BOOKINGS.DESK_NO_MAP_AVAILABLE' + | translate + }} +

+
+
+ } +
+ } +
+ + +
+ + +
+
+ } + `, + styles: [``], + imports: [ + CommonModule, + TranslatePipe, + IconComponent, + MatRippleModule, + MatProgressSpinnerModule, + InteractiveMapComponent, + ], +}) +export class AutoAssignedDeskModalComponent + extends AsyncHandler + implements OnInit +{ + private _state = inject(BookingFormService); + private _org = inject(OrganisationService); + private _settings = inject(SettingsService); + private _router = inject(Router); + private _dialog_ref = inject(MatDialogRef, { + optional: true, + }); + + public readonly show = signal(false); + public readonly show_close = model(false); + public readonly loading = signal(''); + public readonly assigned_desk = signal(null); + public readonly date = model(undefined); + public readonly duration = model(undefined); + /** When set, tries to find a desk nearby to this desk */ + public readonly nearby_desk_id = model(undefined); + /** When set, filters available desks to this level */ + public readonly level_id = model(undefined); + + public zoom = 1.5; + public center = signal({ x: 0.5, y: 0.5 }); + + /** Shared form value on open. Restored if the modal closes with no booking */ + private _initial_form: BookingFormValue | null = null; + private _booked = false; + + public async ngOnInit() { + await this.initializeAndAutoAssign(); + } + + public override ngOnDestroy() { + super.ngOnDestroy(); + // The search needs the date and all-day values in the shared form. + // Put the form back when the user did not confirm a booking. + if (this._booked || !this._initial_form) return; + this._state.model.set(this._initial_form); + } + + private async initializeAndAutoAssign() { + try { + this.loading.set('available'); + this._initial_form = this._state.model(); + + // Initialize booking state for desk + this._state.setOptions({ type: 'desk' }); + this._state.model.update((m) => ({ ...m, booking_type: 'desk' })); + + // Set default values + const now = Date.now(); + const current_date = this._state.model().date; + const booking_date = + this.date() || + (isBefore(current_date || 0, now) + ? startOfMinute(now).valueOf() + : current_date); + const booking_duration = + this.duration() || + this._settings.get('app.desks.default_duration') || + 60; + + this._state.model.update((m) => ({ + ...m, + date: booking_date, + duration: booking_duration, + all_day: true, + })); + + // Get available resources (desks) + let available_desks = await this._state.listAvailableResources(); + + if (!available_desks?.length) { + notifyError(i18n('BOOKINGS.DESK_AUTO_ASSIGN_EMPTY')); + this.dismiss(); + return; + } + this.show.set(true); + + let assigned_desk: BookingAsset; + const nearby_desk_id = this.nearby_desk_id(); + const level_id = this.level_id(); + + // If nearby_desk_id is provided, try to find a desk nearby + if (nearby_desk_id) { + // Filter to same level if level_id is provided + if (level_id) { + const level_desks = available_desks.filter( + (desk) => desk.zone?.id === level_id, + ); + if (level_desks.length > 0) { + available_desks = level_desks; + } + } + + // Try to find the level by looking at nearby desk in all resources + const all_resources = await this._state.listResources(); + const nearby_resource = all_resources.find( + (r) => + r.id === nearby_desk_id || r.map_id === nearby_desk_id, + ); + + if (nearby_resource?.zone?.id) { + const level = this._org.levelWithID([ + nearby_resource.zone.id, + ]); + if (level?.map_id) { + // Use findNearbyFeature to get the closest desk + const desk_ids = available_desks.map( + (d) => d.map_id || d.id, + ); + const closest_id = await findNearbyFeature( + level.map_id, + nearby_desk_id, + desk_ids, + ); + if (closest_id) { + assigned_desk = available_desks.find( + (d) => + d.id === closest_id || + d.map_id === closest_id, + ); + } + } + } + } + + // Fallback to original logic if no nearby desk found + if (!assigned_desk) { + // Prefer desks whose tags or homebase match the current user's groups + const user_groups = currentUser()?.groups || []; + const tag_matched = user_groups.length + ? available_desks.filter( + (desk) => + desk.tags?.length && + desk.tags.some((tag) => + user_groups.includes(tag), + ), + ) + : []; + const homebase_matched = user_groups.length + ? available_desks.filter( + (desk) => + desk.homebase && + user_groups.includes(desk.homebase), + ) + : []; + // Best: desks matching both tags and homebase + const both_matched = tag_matched.filter( + (desk) => + desk.homebase && user_groups.includes(desk.homebase), + ); + // Priority: both > homebase > tags > all + const pool = both_matched.length + ? both_matched + : homebase_matched.length + ? homebase_matched + : tag_matched.length + ? tag_matched + : available_desks; + + // Group desks by level and find level with most available desks + const desks_by_level = pool.reduce( + (acc, desk) => { + const zone_id = desk.zone?.id || 'unknown'; + if (!acc[zone_id]) { + acc[zone_id] = []; + } + acc[zone_id].push(desk); + return acc; + }, + {} as Record, + ); + + // Find the level with the most available desks + const level_with_most_desks = Object.entries( + desks_by_level, + ).sort(([, a], [, b]) => b.length - a.length)[0]; + + // Pick the first desk from the level with most availability + assigned_desk = level_with_most_desks[1][0]; + } + + // The desk goes into the shared form only on confirm() + this.assigned_desk.set(assigned_desk); + + this.loading.set(''); + } catch (error) { + console.error('Error auto-assigning desk:', error); + notifyError(i18n('BOOKINGS.DESK_AUTO_ASSIGN_EMPTY')); + this.dismiss(); + } + } + + public readonly level = computed(() => { + const desk = this.assigned_desk(); + if (!desk?.zone?.id) return null; + return this._org.levelWithID([desk.zone.id]); + }); + + public readonly map_url = computed(() => { + return this.level()?.map_id || ''; + }); + + public readonly location = computed(() => { + const desk = this.assigned_desk(); + if (!desk?.zone?.parent_id) return 'N/A'; + + const building = this._org.buildings.find( + (b) => b.id === desk.zone.parent_id, + ); + return building?.display_name || building?.name || 'N/A'; + }); + + public readonly level_name = computed(() => { + const desk = this.assigned_desk(); + if (!desk?.zone?.id) return 'N/A'; + + const level = this._org.levels.find((l) => l.id === desk.zone.id); + return level?.display_name || level?.name || 'N/A'; + }); + + public readonly desks = this._state.resources; + + // Map features (desk pin for the assigned desk) + public readonly styles = computed(() => { + const assigned = this.assigned_desk(); + if (!assigned) return {}; + + return { + ['#' + assigned.map_id]: { + fill: '#22c55e', + }, + }; + }); + + // Map features (desk pin for the assigned desk) + public readonly features = computed(() => { + const assigned = this.assigned_desk(); + if (!assigned) return []; + + return [ + { + location: assigned.map_id, + content: MapPinComponent, + data: { + message: assigned.name || assigned.id || 'Assigned Desk', + fill: '#22c55e', + stroke: '#fff', + }, + }, + ]; + }); + + public readonly confirm = async () => { + this.loading.set('booking'); + this._state.setOptions({ type: 'desk' }); + this._state.model.update((m) => ({ + ...m, + booking_type: 'desk', + asset_id: this.assigned_desk().id, + resources: [this.assigned_desk()], + })); + try { + if (this._state.options()?.group) { + await this._state.postFormForGroup(); + } else { + await this._state.postForm(); + } + this._booked = true; + await this._router.navigate(['/book', 'desk', 'success']); + this._dialog_ref?.close(true); + } catch (e) { + this.loading.set(''); + notifyError( + typeof e === 'string' + ? e + : i18n(`BOOKINGS.DESK_AUTO_ASSIGN_EMPTY`), + ); + } + }; + + public readonly dismiss = () => { + this._dialog_ref?.close(false); + }; +} diff --git a/apps/workplace/src/app/book/desk-flow/desk-flow-form.component.ts b/apps/workplace/src/app/book/desk-flow/desk-flow-form.component.ts index a4a902cbee..ad99832e71 100644 --- a/apps/workplace/src/app/book/desk-flow/desk-flow-form.component.ts +++ b/apps/workplace/src/app/book/desk-flow/desk-flow-form.component.ts @@ -3,6 +3,7 @@ import { MatBottomSheet, MatBottomSheetRef, } from '@angular/material/bottom-sheet'; +import { MatRippleModule } from '@angular/material/core'; import { Router } from '@angular/router'; import { BookingFormService } from '@placeos/bookings'; import { @@ -77,7 +78,12 @@ import { NewDeskFormDetailsComponent } from './desk-form-details.component'; } `, - imports: [TranslatePipe, NewDeskFormDetailsComponent, IconComponent], + imports: [ + TranslatePipe, + NewDeskFormDetailsComponent, + MatRippleModule, + IconComponent, + ], }) export class NewDeskFlowFormComponent implements OnInit { private _state = inject(BookingFormService); @@ -124,7 +130,7 @@ export class NewDeskFlowFormComponent implements OnInit { } } const { asset_id, resources } = this.model(); - if (resources?.length && !asset_id) { + if (resources?.length && asset_id !== resources[0].id) { this.model.update((m) => ({ ...m, asset_id: resources[0].id })); } if (!this.form().valid()) diff --git a/apps/workplace/src/app/book/desk-flow/desk-form-details.component.ts b/apps/workplace/src/app/book/desk-flow/desk-form-details.component.ts index 425c0c9a1c..2fc4fffff0 100644 --- a/apps/workplace/src/app/book/desk-flow/desk-form-details.component.ts +++ b/apps/workplace/src/app/book/desk-flow/desk-form-details.component.ts @@ -517,6 +517,7 @@ export class NewDeskFormDetailsComponent model.update((m) => ({ ...m, resources: [this.selected_desk], + asset_id: this.selected_desk.id, })); } } diff --git a/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-details.component.ts b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-details.component.ts new file mode 100644 index 0000000000..1ffaec258b --- /dev/null +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-details.component.ts @@ -0,0 +1,359 @@ +import { Component, computed, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FormField } from '@angular/forms/signals'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatRippleModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { + i18n, + notifyError, + OrganisationService, + settingSignal, + SettingsService, +} from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { EventFormService } from '@placeos/events'; +import { + DateFieldComponent, + DurationFieldComponent, + HostSelectFieldComponent, + RecurrenceFieldComponent, + TimeFieldComponent, +} from '@placeos/form-fields'; +import { addDays, endOfDay } from 'date-fns'; +import { ROOM_CAPACITY_OPTIONS, roomCapacityFilters } from './room-capacity'; + +@Component({ + selector: 'meeting-flow-details', + template: ` +
+
+
+ info +
+ {{ + 'CALENDAR_EVENT.MEETING_DETAILS_HEADER' | translate + }} +
+
+
+ + + + +
+
+ + + @if (allow_all_day()) { + + {{ 'COMMON.ALL_DAY' | translate }} + + } +
+
+ + +
+ @if (!allow_multiday()) { +
+ + +
+ } +
+ @if (allow_multiday()) { +
+
+ + +
+
+ + +
+
+ } + @if (can_book_for_others()) { +
+ + +
+ } + @if (allow_recurrence()) { +
+ + + @if (model().id) { + + {{ 'FORM.UPDATE_FUTURE' | translate }} + + } +
+ } +
+
+ info +
+ {{ 'CALENDAR_EVENT.ROOM_SIZE_LABEL' | translate }} +
+
+
+ @let capacity = options()?.capacity || -1; + @for (option of capacity_options; track option.value) { + + } +
+
+
+
+
+ +
+
+ `, + styles: [ + ` + .gradient { + background: linear-gradient( + 105deg, + var(--base-200) 0%, + var(--base-200) 50%, + var(--base-100) 100% + ); + } + `, + ], + imports: [ + MatRippleModule, + IconComponent, + MatFormFieldModule, + MatInputModule, + DateFieldComponent, + DurationFieldComponent, + RecurrenceFieldComponent, + TimeFieldComponent, + FormsModule, + FormField, + RouterModule, + TranslatePipe, + MatCheckboxModule, + HostSelectFieldComponent, + ], +}) +export class MeetingFlowDetailsComponent { + private _route = inject(ActivatedRoute); + private _router = inject(Router); + private _event_form = inject(EventFormService); + private _settings = inject(SettingsService); + private _org = inject(OrganisationService); + + public readonly options = this._event_form.filters; + public readonly form_value = this._event_form.model; + public readonly allow_all_day = settingSignal( + 'events.allow_all_day', + false, + ); + public readonly min_duration = settingSignal('events.min_duration', 30); + public readonly max_duration = settingSignal('events.max_duration', 8 * 60); + public readonly duration_step = settingSignal('events.duration_step', 15); + public readonly use_24hr = settingSignal('use_24_hour_time', false); + public readonly custom_duration_options = settingSignal( + 'events.custom_duration_options', + [], + ); + /** Number of days ahead that the user can book a meeting */ + public readonly allowed_future_days = settingSignal( + 'events.allowed_future_days', + 180, + ); + public readonly end_date = computed(() => + endOfDay(addDays(Date.now(), this.allowed_future_days())).valueOf(), + ); + public readonly can_book_for_others = settingSignal( + 'events.can_book_for_others', + false, + ); + private readonly _allow_multiday = settingSignal( + 'events.allow_multiday', + false, + ); + /** Whether to show an end date and end time in place of the duration */ + public readonly allow_multiday = computed( + () => this._allow_multiday() || this._event_form.is_multiday, + ); + public readonly capacity_options = ROOM_CAPACITY_OPTIONS; + public readonly recurrence_enabled = settingSignal( + 'events.allow_recurrence', + false, + ); + + public readonly bookable_hours = settingSignal< + { start: number; end: number } | undefined + >('events.bookable_hours', undefined); + + public get form() { + return this._event_form.form; + } + + public get model() { + return this._event_form.model; + } + + public get timezone() { + return this._settings.get('app.events.use_building_timezone') + ? this._org.building.timezone + : ''; + } + public readonly has_title = computed( + () => !!this.form_value()?.title?.trim(), + ); + public readonly start_time_disabled = computed( + () => this.form_value().all_day || this.form.date().disabled(), + ); + public readonly setCapacity = (capacity: number) => { + this._event_form.setFilters(roomCapacityFilters(capacity)); + }; + + public readonly allow_recurrence = computed( + () => + this.recurrence_enabled() && this.form_value().duration <= 24 * 60, + ); + + public onFirstInstanceChange(date: number) { + this.model.update((m) => ({ ...m, date })); + } + + public searchRooms() { + if (!this.has_title()) { + notifyError(i18n('CALENDAR_EVENT.ERROR_TITLE_REQUIRED_SEARCH')); + return; + } + + this._router.navigate([], { + relativeTo: this._route, + queryParams: { view: 1 }, + queryParamsHandling: 'merge', + }); + } +} diff --git a/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-options.component.ts b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-options.component.ts new file mode 100644 index 0000000000..be83e20cc2 --- /dev/null +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-options.component.ts @@ -0,0 +1,649 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + ElementRef, + inject, + signal, + ViewChild, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FormField } from '@angular/forms/signals'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { Router } from '@angular/router'; +import { AssetListFieldComponent } from '@placeos/assets'; +import { + CateringListFieldComponent, + CateringOrderStateService, +} from '@placeos/catering'; +import { + currentUser, + formatRecurrence, + fromEventRecurrence, + getInvalidSignalFields, + i18n, + notifyError, + notifySuccess, + OrganisationService, + settingSignal, + SettingsService, +} from '@placeos/common'; +import { + IconComponent, + openConfirmModal, + TranslatePipe, +} from '@placeos/components'; +import { EventFormService } from '@placeos/events'; +import { + RichTextInputComponent, + UserListFieldComponent, +} from '@placeos/form-fields'; +import { FindAvailabilityModalComponent } from '@placeos/users'; + +/** Parts of a selected room that give its level and building */ +interface SpaceLocation { + level?: { id?: string } | string; + zones?: string[]; +} + +@Component({ + selector: 'meeting-flow-options', + template: ` +
+
+ task_alt +
+ {{ 'CALENDAR_EVENT.REVIEW_CONFIRM_HEADER' | translate }} +
+
+
+
+
+
+ done +
+

+ {{ + form_value().title || + ('CALENDAR_EVENT.MEETING_DETAILS_HEADER' + | translate) + }} +

+
+
+
+ today +
{{ form_value().date | date: 'fullDate' }}
+
+ @if (form_value().recurrence?.pattern) { +
+ update +
{{ formatted_recurrence }}
+
+ } +
+ schedule +
+
+ @if (is_multiday) { + {{ form_value().date | date: 'MMM d' }}@if (!form_value().all_day) {, {{ form_value().date | date: time_format }}} + - + {{ form_value().date_end | date: 'MMM d' }}@if (!form_value().all_day) {, {{ form_value().date_end | date: time_format }}} + } @else if (form_value().all_day) { + {{ 'COMMON.ALL_DAY' | translate }} + } @else { + {{ form_value().date | date: time_format }} - {{ form_value().date_end | date: time_format }} ({{ form_value().date | date: 'zzzz' }}) + } +
+ @if (timezone) { +
+ {{ form_value().date | date: time_format : timezone }} - {{ form_value().date_end | date: time_format : timezone }} ({{ form_value().date | date: 'zzzz' : timezone }}) +
+ } +
+
+
+
+ @if (form_value().resources?.length) { +
+
+
+ done +
+

+ {{ + 'CALENDAR_EVENT.SELECTED_ROOM_HEADER' + | translate + }} +

+
+
+ @for (space of form_value().resources; track space.id) { +
+ room_preferences +
+ {{ space.display_name || space.name }} +
+
+ @if (space.capacity) { +
+ group +
+ {{ + 'CALENDAR_EVENT.CAPACITY_PEOPLE' + | translate + : { + capacity: + space.capacity, + } + }} +
+
+ } + @if (getSpaceLocation(space)) { +
+ location_on +
{{ getSpaceLocation(space) }}
+
+ } + @if (space.features?.length) { +
+ feature_search +
+ @for ( + feature of space.features.slice( + 0, + 5 + ); + track feature + ) { + + {{ feature }} + + } + @if (space.features.length > 5) { + + +{{ + space.features.length - + 5 + }} + {{ + 'CALENDAR_EVENT.FEATURES_MORE' + | translate + }} + + } +
+
+ } + } +
+
+ } +
+
+ fork_right +
+ {{ 'CALENDAR_EVENT.OPTIONAL_EXTRAS_HEADER' | translate }} +
+
+
+ @if (!hide_attendees()) { +

+ {{ 'CALENDAR_EVENT.ATTENDEES' | translate }} +

+ + + + } + @if (show_capacity_warning()) { +
+ {{ 'CALENDAR_EVENT.CAPACITY_WARNING' | translate }} +
+ } + @if (has_catering()) { +

+ {{ 'CALENDAR_EVENT.CATERING' | translate }} +

+ + @if (form_value().catering?.length && has_codes()) { + + + + + @for ( + code of filtered_codes(); + track code + ) { + + {{ code }} + + } + + + {{ + 'CALENDAR_EVENT.CATERING_CHARGE_CODE_REQUIRED' + | translate + }} + + + } + @if (form_value().catering?.length) { + + + + {{ + 'CALENDAR_EVENT.CATERING_NOTES_REQUIRED' + | translate + }} + + + } + } + @if (has_assets()) { +

+ {{ 'RESOURCE.ASSETS' | translate }} +

+ + } + @if (!hide_notes()) { +

+ {{ 'CALENDAR_EVENT.NOTES_HEADER' | translate }} +

+ + } +
+
+
+ +
+ `, + styles: [ + ` + .gradient { + background: linear-gradient( + 105deg, + var(--base-200) 0%, + var(--base-200) 50%, + var(--base-100) 100% + ); + } + `, + ], + imports: [ + CommonModule, + IconComponent, + TranslatePipe, + UserListFieldComponent, + FormsModule, + FormField, + MatRippleModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + CateringListFieldComponent, + AssetListFieldComponent, + RichTextInputComponent, + ], +}) +export class MeetingFlowOptionsComponent { + private _event_form = inject(EventFormService); + private _settings = inject(SettingsService); + private _org = inject(OrganisationService); + private _catering = inject(CateringOrderStateService); + private _dialog = inject(MatDialog); + private _router = inject(Router); + + @ViewChild('input') private _input: ElementRef; + + public readonly form_value = this._event_form.model; + public readonly loading = signal(false); + public readonly allow_externals = settingSignal( + 'events.allow_externals', + false, + ); + public readonly has_assets = settingSignal('events.has_assets', false); + public readonly hide_notes = settingSignal('events.hide_notes', false); + public readonly hide_attendees = settingSignal( + 'events.hide_attendees', + false, + ); + public readonly strict_capacity_check = settingSignal( + 'events.strict_capacity_check', + false, + ); + public readonly no_standalone = settingSignal( + 'events.no_standalone', + false, + ); + public readonly booking_unavailable = settingSignal( + 'events.booking_unavailable', + false, + ); + public readonly code_filter = signal(''); + public invalid_assets: string[] = []; + + /** Number of people that the selected rooms hold */ + public readonly total_capacity = computed(() => + (this.form_value().resources || []).reduce( + (total: number, space: { capacity?: number }) => + total + (space.capacity || 0), + 0, + ), + ); + + /** Number of attendees, with the current user included */ + public readonly attendee_count = computed(() => { + const attendees: { email?: string }[] = + this.form_value().attendees || []; + const email = currentUser()?.email?.toLowerCase(); + const has_user = attendees.some( + (_) => _.email?.toLowerCase() === email, + ); + return attendees.length + (has_user ? 0 : 1); + }); + + public readonly show_capacity_warning = computed( + () => + !this.strict_capacity_check() && + this.total_capacity() > 0 && + this.total_capacity() <= + (this.form_value().attendees?.length || 0), + ); + + public get form() { + return this._event_form.form; + } + + public get model() { + return this._event_form.model; + } + + public get is_multiday() { + return this.form_value().duration > 24 * 60; + } + + public readonly has_catering = computed( + () => this._catering.available_menu().length > 0, + ); + + public readonly has_codes = computed( + () => this._catering.charge_codes().length > 0, + ); + + public readonly filtered_codes = computed(() => { + const search = this.code_filter().toLowerCase(); + return this._catering + .charge_codes() + .filter((_) => _.toLowerCase().includes(search)); + }); + + public get time_format() { + return this._settings.time_format; + } + + public get timezone() { + return this._settings.get('app.events.use_building_timezone') + ? this._org.building.timezone + : ''; + } + + public readonly allow_daily_allday_recurrence = settingSignal( + 'events.allow_daily_allday_recurrence', + false, + ); + + public get formatted_recurrence() { + const value = this.form_value(); + return formatRecurrence( + fromEventRecurrence({ + ...value.recurrence, + start: value.recurrence.start || value.date, + }), + value.recurrence.start || value.date, + ); + } + + public getSpaceLocation(space: SpaceLocation | null): string { + if (!space) return ''; + + const parts: string[] = []; + + // Get level information + if (space.level) { + const level = this._org.levelWithID([ + typeof space.level === 'string' + ? space.level + : space.level.id, + ]); + if (level) { + parts.push(level.display_name || level.name); + } + } + + // Get building information + if (space.zones?.length) { + const building = this._org.buildings.find((b) => + space.zones.includes(b.id), + ); + if (building) { + parts.push(building.display_name || building.name); + } + } + + return parts.filter(Boolean).join(', '); + } + + public focusInput() { + setTimeout(() => this._input?.nativeElement?.focus(), 100); + } + + public async confirmBooking() { + const value = this.form_value(); + const space = value.resources[0]; + if (!value.host) { + this.model.update((m) => ({ ...m, host: currentUser()?.email })); + } + if ( + this.strict_capacity_check() && + this.attendee_count() > this.total_capacity() + ) { + return notifyError(i18n('CALENDAR_EVENT.CAPACITY_ERROR')); + } + if ( + !this.allow_daily_allday_recurrence() && + value.all_day && + value.recurrence?.pattern === 'daily' + ) { + return notifyError(i18n('CALENDAR_EVENT.DAILY_RECURR_ERROR')); + } + this.form().markAsTouched(); + if (!this.form().valid()) { + return notifyError( + i18n('FORM.INVALID_FIELDS', { + field_list: getInvalidSignalFields( + this.form, + this.model, + ).join(', '), + }), + ); + } + if (this.no_standalone() && !space) { + return notifyError(i18n('CALENDAR_EVENT.ROOM_REQUIRED')); + } + if (this.booking_unavailable()) { + return this._event_form.openEventLinkModal(); + } + if (!space) { + const result = await openConfirmModal( + { + title: i18n('APP.WORKPLACE.MEETING_WITHOUT_ROOM_TITLE'), + content: i18n('APP.WORKPLACE.MEETING_WITHOUT_ROOM_MSG'), + icon: { content: 'event_available' }, + }, + this._dialog, + ); + if (result.reason !== 'done') return; + } + this.loading.set(true); + try { + await this._event_form.postForm().catch((_) => { + notifyError(_); + throw _; + }); + this._router.navigate(['/book', 'meeting', 'success']); + notifySuccess(i18n('APP.WORKPLACE.MEETING_BOOKED')); + } finally { + this.loading.set(false); + } + } + + public findAvailableTime() { + const { attendees, organiser, date, duration } = this.model(); + const ref = this._dialog.open(FindAvailabilityModalComponent, { + data: { + users: attendees ?? [], + host: organiser || currentUser(), + date, + duration, + }, + }); + ref.afterClosed().subscribe((d) => { + if (!d) return; + this.model.update((m) => ({ + ...m, + date: ref.componentInstance.date(), + attendees: ref.componentInstance.users(), + duration: ref.componentInstance.duration(), + })); + }); + } +} diff --git a/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-list.component.ts b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-list.component.ts new file mode 100644 index 0000000000..dcc4b2ada5 --- /dev/null +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-list.component.ts @@ -0,0 +1,330 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + inject, + model, + output, + signal, +} from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; +import { + SETTING_KEYS, + settingSignal, + SettingsService, + Space, +} from '@placeos/common'; +import { IconComponent, LevelPipe, TranslatePipe } from '@placeos/components'; +import { EventFormService } from '@placeos/events'; + +@Component({ + selector: 'meeting-flow-space-list', + template: ` + @if (!loading()) { + @if (available_spaces()?.length) { +
    + @for ( + space of available_spaces() + | slice + : active_page() * page_size() + : active_page() * page_size() + page_size(); + track space.id + ) { +
  • +
    + +
    + + @if (space.approval) { +
    + {{ 'COMMON.APPROVAL_REQUIRED' | translate }} +
    + } +
  • + } +
+ @if (available_spaces().length > page_size()) { +
+
+ @let range = active_range(); + {{ range[0] }} - {{ range[1] }} +
+
+
+ Page {{ active_page() + 1 }} of {{ max_pages() }} +
+ + +
+ } + } @else { +
+

+ {{ 'CALENDAR_EVENT.SPACE_SELECT_EMPTY' | translate }} +

+
+ } + } @else { +
+ +

+ {{ 'CALENDAR_EVENT.SPACE_SELECT_LOADING' | translate }} + +

+
+ } + `, + styles: [ + ` + :host { + display: flex; + flex-direction: column; + min-height: 100%; + } + `, + ], + imports: [ + CommonModule, + TranslatePipe, + MatProgressSpinnerModule, + LevelPipe, + IconComponent, + MatRippleModule, + MatTooltipModule, + AuthenticatedImageDirective, + ], +}) +export class MeetingFlowSpaceListComponent { + private _event_form = inject(EventFormService); + private _settings = inject(SettingsService); + + public readonly selected_spaces = model([]); + public readonly space_selected = output(); + + public readonly loading = this._event_form.loading; + public readonly available_spaces = this._event_form.available_spaces; + public readonly room_alerts = this._event_form.room_alerts; + + public readonly page = signal(0); + public readonly page_size = signal(10); + public readonly max_pages = computed(() => + Math.ceil(this.available_spaces().length / this.page_size()), + ); + /** Selected page, limited to the last page of the current room list */ + public readonly active_page = computed(() => + Math.max(0, Math.min(this.page(), this.max_pages() - 1)), + ); + public readonly active_range = computed(() => [ + this.active_page() * this.page_size() + 1, + Math.min( + this.active_page() * this.page_size() + this.page_size(), + this.available_spaces()?.length, + ), + ]); + + public readonly favourites = settingSignal( + SETTING_KEYS.FAVORITE_ROOMS, + [], + true, + ); + + public toggleFavourite(space: Space) { + const existing = this.favourites(); + const updated = existing.find((id) => space.id === id) + ? existing.filter((id) => id !== space.id) + : [...existing, space.id]; + + // Optimistically update UI + this.favourites.set(updated); + + // Save to settings in background + this._settings.saveUserSetting(SETTING_KEYS.FAVORITE_ROOMS, updated); + } +} diff --git a/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-map.component.ts b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-map.component.ts new file mode 100644 index 0000000000..e78eb04079 --- /dev/null +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-map.component.ts @@ -0,0 +1,250 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + effect, + inject, + input, + output, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { + AsyncHandler, + BuildingLevel, + flatten, + OrganisationService, + settingSignal, + SettingsService, + Space, +} from '@placeos/common'; +import { + AuthenticatedImageDirective, + BuildingPipe, + IconComponent, + InteractiveMapComponent, + LevelPipe, + TranslatePipe, +} from '@placeos/components'; +import { EventFormService } from '@placeos/events'; +import { DEFAULT_COLOURS } from '@placeos/explore'; + +@Component({ + selector: 'meeting-flow-space-map', + template: ` + @if (levels()?.length) { +
+ + + @for (lvl of levels(); track lvl) { + +
+ @if (use_region()) { +
+ {{ + (lvl?.parent_id | building) + ?.display_name + }} + - +
+ } +
{{ lvl.display_name || lvl.name }}
+
+
+ } +
+
+
+ } +
+ +
+ @if (selected_space()) { +
+
+ task_alt + @if (selected_space().images?.length) { + + } @else { + + } +
+
+
+ {{ + selected_space().display_name || + selected_space().name || + selected_space().id + }} +
+
+ place +

+ @let lvl = selected_space().zones | level; + {{ + lvl?.display_name || lvl?.name + }} +

+
+
+
+ {{ 'COMMON.SELECTED' | translate }} +
+
+ } + `, + styles: [``], + imports: [ + CommonModule, + InteractiveMapComponent, + MatFormFieldModule, + MatSelectModule, + FormsModule, + IconComponent, + TranslatePipe, + LevelPipe, + BuildingPipe, + AuthenticatedImageDirective, + ], +}) +export class MeetingFlowSpaceMapComponent extends AsyncHandler { + private _event_form = inject(EventFormService); + private _org = inject(OrganisationService); + private _settings = inject(SettingsService); + + public readonly selected_spaces = input([]); + public readonly active = input(undefined); + public readonly is_displayed = input(false); + public readonly space_selected = output(); + + public readonly zoom = signal(1); + public readonly center = signal({ x: 0.5, y: 0.5 }); + public readonly coordinates = signal(undefined); + + private _selectedSpace = (s) => () => this.space_selected.emit(s); + public readonly setOptions = (o) => this._event_form.setOptions(o); + public readonly level = signal(null); + public readonly use_region = settingSignal('use_region', false); + public readonly available_spaces = this._event_form.available_spaces; + + public readonly map_url = computed(() => this.level()?.map_id || ''); + public readonly space_list = this._event_form.spaces; + public readonly features = signal([]); + public readonly levels = computed(() => { + const region = this._org.active_region(); + const bld = this._org.active_building(); + const spaces = this._event_form.spaces(); + const level_list = this.use_region() + ? this._org.levelsForRegion(region) + : this._org.levelsForBuilding(bld); + const level_ids = new Set( + flatten(spaces.map((space) => space.zones || [])), + ); + return level_list + .filter( + (lvl) => + !lvl.tags.includes('parking') && level_ids.has(lvl.id), + ) + .sort( + (a, b) => + a.parent_id.localeCompare(b.parent_id) || + (a.display_name || '').localeCompare(b.display_name || ''), + ); + }); + + public readonly selected_space = computed(() => { + const selected_ids = this.selected_spaces(); + if (!selected_ids?.length) return null; + const available = this.available_spaces(); + return ( + available.find((space) => selected_ids.includes(space.id)) || null + ); + }); + + public readonly actions = computed(() => + this.available_spaces().map((space) => ({ + id: space.map_id, + action: ['touchend', 'mouseup'], + callback: this._selectedSpace(space), + })), + ); + + public readonly styles = computed(() => { + const free_spaces = this.available_spaces(); + const spaces = this.space_list(); + return spaces.reduce((styles, space) => { + const colours = this._settings.get('app.explore.colors') || {}; + const status = free_spaces.find((_) => _.id === space.id) + ? this.selected_spaces().includes(space.id) + ? 'pending' + : 'free' + : 'busy'; + styles[`#${space.map_id || space.id}`] = { + fill: + colours[`space-${status}`] || + colours[`${status}`] || + DEFAULT_COLOURS[`${status}`], + }; + return styles; + }, {}); + }); + + private readonly _levels_update = effect(() => { + const { zones } = this._event_form.options(); + const level = this._org.levelWithID(zones); + if (level) this.level.set(level); + }); + + public setLevel(level: BuildingLevel) { + this.setOptions({ zones: [level?.id] }); + const bld = this._org.buildings.find((_) => _.id === level?.parent_id); + if (bld) { + const [latitude, longitude] = (level.location || bld.location) + .split(',') + .map((_) => parseFloat(_)); + this.coordinates.set({ latitude, longitude }); + } + this.level.set(level); + } + + public setZoom(new_zoom: number) { + this.zoom.set(Math.max(0.5, Math.min(10, new_zoom))); + } + + public resetMap() { + this.zoom.set(1); + this.center.set({ x: 0.5, y: 0.5 }); + } +} diff --git a/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-select.component.ts b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-select.component.ts new file mode 100644 index 0000000000..9e6887f13b --- /dev/null +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-select.component.ts @@ -0,0 +1,946 @@ +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FormField } from '@angular/forms/signals'; +import { MatRippleModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { + flatten, + i18n, + notifyError, + OrganisationService, + settingSignal, + SettingsService, + Space, + unique, +} from '@placeos/common'; +import { + BuildingPipe, + IconComponent, + LevelPipe, + SettingsToggleComponent, + TranslatePipe, +} from '@placeos/components'; +import { + EventFormFilters, + EventFormService, + multipleSpacesEnabled, + SpacesService, +} from '@placeos/events'; +import { + DateFieldComponent, + DurationFieldComponent, + TimeFieldComponent, +} from '@placeos/form-fields'; +import { addDays, endOfDay } from 'date-fns'; +import { MeetingFlowSpaceListComponent } from './meeting-flow-space-list.component'; +import { MeetingFlowSpaceMapComponent } from './meeting-flow-space-map.component'; +import { ROOM_CAPACITY_OPTIONS, roomCapacityFilters } from './room-capacity'; + +@Component({ + selector: 'meeting-flow-space-select', + template: ` +
+
+
+ info +
+ {{ 'CALENDAR_EVENT.SELECT_ROOM_HEADER' | translate }} +
+
+ +
+ + @if (filters_open()) { +
+ } +
+ + + +
+
+
+ @let feature_list = + filters()?.features || []; + @let zones = options()?.zones || []; + @let capacity = filters()?.capacity || -1; + @let event = model(); + @let zone = (zones | level) || (zones | building); + @let location = + zone?.display_name || zone?.name || ''; + @if (feature_list.length > 1) { + + } + @if (location) { +
+ {{ location }} +
+ } +
+ {{ event.date | date: 'mediumDate' }} +
+
+ @if (!event.all_day) { + {{ event.date | date: time_format }} — + {{ event.date_end | date: time_format }} + } + @if (event.all_day) { + {{ 'COMMON.ALL_DAY' | translate }} + } +
+
+ @if (capacity < 0) { + {{ 'COMMON.CAPACITY_ANY' | translate }} + } @else { + {{ + 'CALENDAR_EVENT.SPACE_SELECT_SIZE_X' + | translate + : { + count: capacity || 2, + } + }} + } +
+ @for (feat of feature_list; track feat) { +
+

{{ feat }}

+ +
+ } +
+
+ + +
+
+
+ @if (view() === 'map') { + + } @else { + + } +
+
+
+ +
+
+

+ {{ 'COMMON.FILTERS' | translate }} +

+ +
+
+ + @if (use_region() && (regions())?.length) { + + + @for (reg of regions(); track reg) { + + {{ reg.display_name || reg.name }} + + } + + + } + @if (!use_region() && (buildings())?.length > 1) { + + + @for (bld of buildings(); track bld) { + + {{ bld.display_name || bld.name }} + + } + + + } + @if (view() === 'list') { + + + @for (lvl of levels(); track lvl) { + +
+ @if (use_region()) { +
+ {{ + ( + lvl?.parent_id + | building + )?.display_name + }} + + - + +
+ } +
+ {{ + lvl.display_name || lvl.name + }} +
+
+
+ } +
+
+ } + + + + @for (opt of capacity_options; track opt.value) { + + {{ opt.label | translate }} + + } + + + + + @if (allow_all_day()) { + {{ 'COMMON.ALL_DAY' | translate }} + } + @if (!field('all_day')) { + +
+ + @if (!allow_multiday()) { + + } +
+ } + @if (allow_multiday()) { + + + @if (!field('all_day')) { + + + } + } + @if (view() === 'list') { + {{ + 'COMMON.FAVOURITES_ONLY' | translate + }} + } + @if (view() === 'list') { + @if (features()?.length) { +

+ {{ 'CALENDAR_EVENT.FACILITIES' | translate }} +

+
+ @for (feat of features(); track feat) { + @if (!hide_features().includes(feat)) { + + } + } +
+ } + } +
+
+
+
+
+ + {{ 'COMMON.BACK' | translate }} + + +
+ `, + styles: [ + ` + .gradient { + background: linear-gradient( + 105deg, + var(--base-200) 0%, + var(--base-200) 50%, + var(--base-100) 100% + ); + } + + .filters-panel-mobile { + border-radius: 1rem 1rem 0 0; + max-height: 70vh; + } + + [filter-item] { + display: flex; + align-items: center; + padding: 0 1rem; + min-height: 2rem; + height: 2rem; + font-size: 0.875rem; + border: 1px solid var(--base-300); + border-radius: 1.25rem; + margin: 0.25rem; + max-width: 100%; + text-align: center; + } + + [filter-item]:hover { + background: rgba(0, 0, 0, 0.1); + } + + [filter-item] > * + * { + margin-left: 0.5rem; + } + `, + ], + imports: [ + CdkScrollable, + CommonModule, + MatRippleModule, + IconComponent, + MatFormFieldModule, + MatSelectModule, + DateFieldComponent, + TimeFieldComponent, + DurationFieldComponent, + FormsModule, + FormField, + SettingsToggleComponent, + TranslatePipe, + BuildingPipe, + LevelPipe, + MeetingFlowSpaceListComponent, + MeetingFlowSpaceMapComponent, + RouterModule, + ], +}) +export class MeetingFlowSpaceSelectComponent { + private _route = inject(ActivatedRoute); + private _router = inject(Router); + private _event_form = inject(EventFormService); + private _org = inject(OrganisationService); + private _settings = inject(SettingsService); + private _spaces = inject(SpacesService); + + public readonly prev = output(); + public readonly next = output(); + + public readonly use_24hr = settingSignal('use_24_hour_time', false); + public readonly use_region = settingSignal('use_region', false); + public readonly min_duration = settingSignal('events.min_duration', 30); + public readonly max_duration = settingSignal('events.max_duration', 8 * 60); + public readonly duration_step = settingSignal('events.duration_step', 15); + public readonly custom_duration_options = settingSignal( + 'events.custom_duration_options', + [], + ); + public readonly allow_all_day = settingSignal( + 'events.allow_all_day', + false, + ); + public readonly feature_display = settingSignal>( + 'events.feature_decriptions', + {}, + ); + public readonly hide_features = settingSignal( + 'events.hide_features', + [], + ); + public readonly view = signal<'map' | 'list'>('list'); + public readonly filters_open = signal(false); + + /** Number of days ahead that the user can book a meeting */ + public readonly allowed_future_days = settingSignal( + 'events.allowed_future_days', + 180, + ); + public readonly end_date = computed(() => + endOfDay(addDays(Date.now(), this.allowed_future_days())).valueOf(), + ); + public readonly bookable_hours = settingSignal< + { start: number; end: number } | undefined + >('events.bookable_hours', undefined); + private readonly _allow_multiday = settingSignal( + 'events.allow_multiday', + false, + ); + /** Whether to show an end date and end time in place of the duration */ + public readonly allow_multiday = computed( + () => this._allow_multiday() || this._event_form.is_multiday, + ); + + public readonly capacity_options = ROOM_CAPACITY_OPTIONS; + + public get form() { + return this._event_form.form; + } + + public get model() { + return this._event_form.model; + } + + private readonly form_value = this._event_form.model; + + public readonly selected = computed(() => { + const resources = this.form_value().resources || []; + return resources.map(({ id }) => id); + }); + + public readonly has_space = computed( + () => + !!this.form_value()?.resources && + this.form_value()?.resources.length > 0, + ); + + public readonly options = this._event_form.options; + public readonly filters = this._event_form.filters; + + public readonly building = this._org.active_building; + public readonly buildings = this._org.active_buildings; + public readonly region = this._org.active_region; + public readonly regions = this._org.region_list; + + public readonly setBuilding = (bld) => (this._org.building = bld); + public readonly setRegion = (region) => (this._org.region = region); + public readonly setOptions = (o) => this._event_form.setOptions(o); + public readonly setFilters = (f: Partial) => + this._event_form.setFilters(f); + public readonly setCapacity = (capacity: number) => + this._event_form.setFilters(roomCapacityFilters(capacity)); + public readonly loading = this._event_form.loading; + + public readonly room_alerts = this._event_form.room_alerts; + public readonly active = signal(''); + private readonly bookable_spaces = this._event_form.spaces; + + public readonly levels = computed(() => { + const region = this._org.active_region(); + const bld = this._org.active_building(); + const spaces = this._event_form.spaces(); + const level_list = this.use_region() + ? this._org.levelsForRegion(region) + : this._org.levelsForBuilding(bld); + const level_ids = new Set( + flatten(spaces.map((space) => space.zones || [])), + ); + const viewable_levels = level_list.filter( + (lvl) => !lvl.tags.includes('parking') && level_ids.has(lvl.id), + ); + return viewable_levels.sort( + (a, b) => + a.parent_id.localeCompare(b.parent_id) || + (a.name || '').localeCompare(b.name || '') || + (a.display_name || '').localeCompare(b.display_name || ''), + ); + }); + + public readonly features = computed(() => { + const features = this._spaces.features(); + const spaces = this._event_form.available_spaces(); + return unique(features.concat(flatten(spaces.map((_) => _.features)))); + }); + + public get timezone() { + return this._settings.get('app.events.use_building_timezone') + ? this._org.building.timezone + : ''; + } + + public get time_format() { + return this.use_24hr() ? 'HH:mm' : 'h:mm a'; + } + + public field(name: string) { + return this.model()?.[name]; + } + + public async toggleFeature(feat: string, state: boolean) { + const { features } = this._event_form.filters(); + const new_list = (features || []).filter((_) => feat !== _); + if (state) new_list.push(feat); + this._event_form.setFilters({ features: new_list }); + } + + public removeFeature(feat: string) { + const { features } = this._event_form.filters(); + const new_list = (features || []).filter((_) => feat !== _); + this._event_form.setFilters({ features: new_list }); + } + + public removeAllFeatures() { + this._event_form.setFilters({ features: [] }); + } + + public setMapView() { + this.view.set('map'); + const level_list = this.use_region() + ? this._org.levelsForRegion(this._org.region) + : this._org.levelsForBuilding(this._org.building); + const level_ids = new Set( + flatten(this.bookable_spaces().map((space) => space.zones || [])), + ); + const viewable_levels = level_list.filter( + (lvl) => !lvl.tags.includes('parking') && level_ids.has(lvl.id), + ); + if (viewable_levels.length) { + const current_zones = this._event_form.options()?.zones || []; + const current_zone_valid = current_zones.some((zone) => + viewable_levels.some((lvl) => lvl.id === zone), + ); + if (current_zone_valid) { + const valid_zone = current_zones.find((zone) => + viewable_levels.some((lvl) => lvl.id === zone), + ); + this.setOptions({ zones: [valid_zone] }); + } else { + const first_level = viewable_levels.sort( + (a, b) => + a.parent_id.localeCompare(b.parent_id) || + (a.display_name || '').localeCompare( + b.display_name || '', + ), + )[0]; + this.setOptions({ zones: [first_level.id] }); + } + } + } + + public toggleSpace(space: Space) { + const resources = this.field('resources') || []; + if (multipleSpacesEnabled(this._settings)) { + const new_resources = resources.find(({ id }) => id === space.id) + ? resources.filter(({ id }) => id !== space.id) + : [...resources, space]; + this.model.update((m) => ({ ...m, resources: new_resources })); + // selected signal will update automatically via computed + } else { + this.model.update((m) => ({ ...m, resources: [space] })); + // selected signal will update automatically via computed + // Close filters on mobile after selecting a space + this.filters_open.set(false); + } + } + + public continue() { + if (!this.has_space()) { + notifyError(i18n('CALENDAR_EVENT.ERROR_ROOM_REQUIRED')); + return; + } + + this._router.navigate([], { + relativeTo: this._route, + queryParams: { view: 2 }, + queryParamsHandling: 'merge', + }); + } +} diff --git a/apps/workplace/src/app/book/meeting-flow-new/meeting-flow.component.ts b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow.component.ts new file mode 100644 index 0000000000..51f8c3d56c --- /dev/null +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow.component.ts @@ -0,0 +1,210 @@ +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { AsyncHandler, i18n, notifyError } from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { EventFormService } from '@placeos/events'; +import { MeetingFlowSuccessComponent } from '../meeting-flow/meeting-flow-success.component'; +import { MeetingFlowDetailsComponent } from './meeting-flow-details.component'; +import { MeetingFlowOptionsComponent } from './meeting-flow-options.component'; +import { MeetingFlowSpaceSelectComponent } from './meeting-flow-space-select.component'; + +@Component({ + selector: 'meeting-flow-new', + template: ` + @if (flow_view() !== 'success') { + + } @else { + + } + `, + styles: [ + ` + :host { + height: 100%; + } + `, + ], + imports: [ + CdkScrollable, + IconComponent, + MatRippleModule, + MeetingFlowDetailsComponent, + MeetingFlowSpaceSelectComponent, + MeetingFlowOptionsComponent, + RouterModule, + TranslatePipe, + MeetingFlowSuccessComponent, + ], +}) +export class MeetingFlowNewComponent extends AsyncHandler implements OnInit { + private _route = inject(ActivatedRoute); + private _router = inject(Router); + private _event_form = inject(EventFormService); + + public readonly view = signal(0); + public readonly flow_view = this._event_form.view; + public readonly form_value = this._event_form.model; + + public readonly has_title = computed( + () => !!this.form_value()?.title?.trim(), + ); + + public readonly has_space = computed( + () => + !!this.form_value()?.resources && + this.form_value()?.resources.length > 0, + ); + + public readonly previous = () => + this.view.update((u) => Math.max(0, u - 1)); + public readonly next = () => this.view.update((u) => u + 1); + + public navigateToView(target_view: number) { + // Check requirements based on target view + if (target_view === 1 && !this.has_title()) { + notifyError(i18n('CALENDAR_EVENT.ERROR_TITLE_REQUIRED_SELECT')); + return; + } + if (target_view === 2 && !this.has_space()) { + notifyError(i18n('CALENDAR_EVENT.ERROR_ROOM_REQUIRED')); + return; + } + + // Navigate if requirements are met + this._router.navigate([], { + relativeTo: this._route, + queryParams: { view: target_view }, + queryParamsHandling: 'merge', + }); + } + + public ngOnInit() { + this._event_form.loadForm(); + this.subscription( + 'route.params', + this._route.paramMap.subscribe((param) => { + if (param.has('step')) + this._event_form.setView( + param.get('step') as Parameters< + EventFormService['setView'] + >[0], + ); + }), + ); + this.subscription( + 'route.query', + this._route.queryParamMap.subscribe((params) => { + if (params.has('view')) { + if (Number.isNaN(+params.get('view'))) return; + this.view.set(+params.get('view')); + } + }), + ); + } +} diff --git a/apps/workplace/src/app/book/meeting-flow-new/room-capacity.ts b/apps/workplace/src/app/book/meeting-flow-new/room-capacity.ts new file mode 100644 index 0000000000..1515136719 --- /dev/null +++ b/apps/workplace/src/app/book/meeting-flow-new/room-capacity.ts @@ -0,0 +1,28 @@ +import { EventFormFilters } from '@placeos/events'; + +export interface RoomCapacityOption { + /** Minimum number of people. `-1` is any room size */ + value: number; + /** Maximum number of people. Empty is no upper limit */ + max?: number; + /** Translation key of the option label */ + label: string; + icon: string; +} + +/** Room size options of the new meeting flow */ +export const ROOM_CAPACITY_OPTIONS: readonly RoomCapacityOption[] = [ + { value: -1, label: 'COMMON.CAPACITY_ANY', icon: 'person' }, + { value: 1, max: 2, label: 'CALENDAR_EVENT.ROOM_SIZE_1_2', icon: 'person' }, + { value: 3, max: 4, label: 'CALENDAR_EVENT.ROOM_SIZE_3_4', icon: 'group' }, + { value: 5, max: 8, label: 'CALENDAR_EVENT.ROOM_SIZE_5_8', icon: 'groups' }, + { value: 9, label: 'CALENDAR_EVENT.ROOM_SIZE_9_PLUS', icon: 'groups' }, +]; + +/** Room filters for the size option with the given minimum capacity */ +export function roomCapacityFilters( + capacity: number, +): Pick { + const option = ROOM_CAPACITY_OPTIONS.find((_) => _.value === capacity); + return { capacity, capacity_max: option?.max }; +} diff --git a/apps/workplace/src/app/book/meeting-flow/meeting-flow-success.component.ts b/apps/workplace/src/app/book/meeting-flow/meeting-flow-success.component.ts index 2a7335e639..0017dd2696 100644 --- a/apps/workplace/src/app/book/meeting-flow/meeting-flow-success.component.ts +++ b/apps/workplace/src/app/book/meeting-flow/meeting-flow-success.component.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { Component, computed, inject, OnInit, signal } from '@angular/core'; import { Router, RouterModule } from '@angular/router'; import { + BuildingLevel, formatRecurrence, fromEventRecurrence, OrganisationService, @@ -16,8 +17,8 @@ import { EventFormService, SpacePipe } from '@placeos/events'; template: ` @let details = { - level: level?.display_name || level?.name, - space: space?.display_name || space?.name, + level: level().display_name || level().name, + space: space().display_name || space().name, date: last_event()?.date | date: 'mediumDate', time: (last_event()?.date | date: time_format) + @@ -48,7 +49,7 @@ import { EventFormService, SpacePipe } from '@placeos/events';

@if (last_event()?.all_day) { {{ - (space + (space().email ? 'CALENDAR_EVENT.SUCCESS_WITH_SPACE_ALLDAY' : 'CALENDAR_EVENT.SUCCESS_WITHOUT_SPACE_ALLDAY' ) | translate: details @@ -56,7 +57,7 @@ import { EventFormService, SpacePipe } from '@placeos/events'; } @if (!last_event()?.all_day) { {{ - (space + (space().email ? 'CALENDAR_EVENT.SUCCESS_WITH_SPACE' : 'CALENDAR_EVENT.SUCCESS_WITHOUT_SPACE' ) | translate: details @@ -75,13 +76,19 @@ import { EventFormService, SpacePipe } from '@placeos/events'; }

- @if (space?.email && allow_desk_booking) { + @if (space().email && allow_desk_booking) { } @@ -114,8 +121,12 @@ export class MeetingFlowSuccessComponent implements OnInit { private _space_pipe = inject(SpacePipe); public readonly loading = signal(false); + public readonly desk_loading = signal(false); public readonly last_event = this._event_form.last_success; - public readonly resolved_space = signal(null); + public readonly space = signal(new Space()); + public readonly level = computed(() => { + return this._org.levelWithID(this.space().zones) || new BuildingLevel(); + }); public readonly formatted_recurrence = computed(() => { const event = this.last_event(); if (!event?.recurrence?.pattern) return ''; @@ -136,33 +147,28 @@ export class MeetingFlowSuccessComponent implements OnInit { ); } - public get space() { - return this.resolved_space() || this.last_event()?.space; - } - - public get level() { - return ( - this._org.levelWithID(this.space?.zones || []) || this.space?.level - ); - } - public get time_format() { return this._settings.time_format; } public async ngOnInit() { this.loading.set(true); - const space = this.last_event()?.space; - if (space?.email || space?.id) { - const resolved_space = await this._space_pipe.transform( - space.email || space.id, - ); - if ( - resolved_space && - (resolved_space.id || - resolved_space.email !== 'empty.space@place.os') - ) { - this.resolved_space.set(resolved_space); + const event_space = this.last_event()?.space; + if (event_space) { + this.space.set(new Space(event_space)); + try { + const resolved_space = await this._space_pipe.transform( + event_space.email || event_space.id, + ); + if ( + resolved_space && + (resolved_space.id || + resolved_space.email !== 'empty.space@place.os') + ) { + this.space.set(new Space(resolved_space)); + } + } catch { + /* Falls back to event space details when org data is unavailable */ } } setTimeout(() => this.loading.set(false), 500); @@ -171,7 +177,7 @@ export class MeetingFlowSuccessComponent implements OnInit { public startDeskBooking() { this._router.navigate(['/book', 'desk', 'form'], { queryParams: { - nearby_space: this.space.id || this.space.email, + nearby_space: this.space().id || this.space().email, date: this.last_event().date, }, }); diff --git a/apps/workplace/src/app/book/parking-flow/parking-flow-form.component.ts b/apps/workplace/src/app/book/parking-flow/parking-flow-form.component.ts index dbf1548f1e..ec4196ec55 100644 --- a/apps/workplace/src/app/book/parking-flow/parking-flow-form.component.ts +++ b/apps/workplace/src/app/book/parking-flow/parking-flow-form.component.ts @@ -187,7 +187,7 @@ export class ParkingFlowFormComponent extends AsyncHandler implements OnInit { public readonly viewConfirm = () => { const { asset_id, resources, date } = this.model(); - if (resources?.length && !asset_id) { + if (resources?.length && asset_id !== resources[0].id) { this.model.update((m) => ({ ...m, asset_id: resources[0].id })); } if (!date) { diff --git a/apps/workplace/src/app/book/parking-request-flow/parking-request-form-details.component.ts b/apps/workplace/src/app/book/parking-request-flow/parking-request-form-details.component.ts index ac8ee44dfb..c34a405d67 100644 --- a/apps/workplace/src/app/book/parking-request-flow/parking-request-form-details.component.ts +++ b/apps/workplace/src/app/book/parking-request-flow/parking-request-form-details.component.ts @@ -15,6 +15,8 @@ import { import { FormsModule } from '@angular/forms'; import { FormField } from '@angular/forms/signals'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatRadioModule } from '@angular/material/radio'; @@ -55,6 +57,7 @@ import { startOfWeek, } from 'date-fns'; import { SettingsToggleComponent } from 'libs/components/src/lib/settings-toggle.component'; +import { FullscreenEmbedComponent } from '../../components/fullscreen-embed.component'; interface ParkingRequestShiftOption { id: string; @@ -119,26 +122,8 @@ const ALL_DAY_START_MINS = 0; const ALL_DAY_END_MINS = 1440; const DEFAULT_DAY_DURATION_MINS = 540; -const DEFAULT_SHIFT_OPTIONS: ParkingRequestShiftOption[] = [ - // { - // id: 'business', - // name: 'BOOKINGS.PARKING_SHIFT_BUSINESS', - // start_time: 540, - // end_time: 1020, - // }, - // { - // id: 'morning', - // name: 'BOOKINGS.PARKING_SHIFT_MORNING', - // start_time: 360, - // end_time: 720, - // }, - // { - // id: 'afternoon', - // name: 'BOOKINGS.PARKING_SHIFT_AFTERNOON', - // start_time: 720, - // end_time: 1080, - // }, -]; +/** Shift presets come only from the `parking.request_shift_options` setting */ +const DEFAULT_SHIFT_OPTIONS: ParkingRequestShiftOption[] = []; const DEFAULT_VEHICLE_TYPE_OPTIONS: VehicleTypeOption[] = [ { id: 'car', name: 'BOOKINGS.PARKING_VEHICLE_CAR' }, @@ -154,13 +139,15 @@ const DEFAULT_VEHICLE_TYPE_OPTIONS: VehicleTypeOption[] = [ @if (form() && model) {
-
-

- date_range +
+ date_range +
{{ 'BOOKINGS.PARKING_BOOKING_FREQUENCY' | translate }} -

+
+
+
-
-

- ballot +
+ ballot +
{{ 'BOOKINGS.PARKING_REQUEST_TYPE' | translate }} -

+
+
+
@for (type of request_types(); track trackById(type)) {
@if (type.badge) { {{ type.badge | translate }} }
+ @if ( + model().request_type === 'special' && show_special_needs() + ) { + +
+ description +
+ {{ + 'BOOKINGS.P2_SPECIAL_NEEDS_DETAILS' | translate + }} +
+
+
+
+ + +
+ +
+
+ {{ + 'BOOKINGS.P2_ATTACH_SUPPORTING_DOCS' + | translate + }} +
+ + + @if (supporting_doc_names().length) { +
+ @for ( + file_name of supporting_doc_names(); + track $index + ) { +
+
+ {{ file_name }} +
+
+ + +
+
+ } +
+ } +
+
+ } -
-

- schedule +
+ schedule +
{{ 'BOOKINGS.PARKING_SHIFT_SELECTION' | translate }} -

+
+
+
@if (forced_request_time(); as forced_time) {
@if (hasMultipleBuildings(building_list())) {
-

- place + place +
{{ 'BOOKINGS.PARKING_LOCATION_PREFERENCE' | translate }} -

+
+
+
@if (region_name) {
{{ region_name }} @@ -886,7 +992,7 @@ const DEFAULT_VEHICLE_TYPE_OPTIONS: VehicleTypeOption[] = [
} @else {

} -
-

- directions_car +
+ directions_car +
{{ 'BOOKINGS.PARKING_VEHICLE_DETAILS' | translate }} -

+
+

+
+

{{ 'BOOKINGS.PARKING_SPACE_RESTRICTIONS_DESC' @@ -1187,7 +1294,18 @@ const DEFAULT_VEHICLE_TYPE_OPTIONS: VehicleTypeOption[] = [

} `, - styles: [``], + styles: [ + ` + .gradient { + background: linear-gradient( + 105deg, + var(--base-200) 0%, + var(--base-200) 50%, + var(--base-100) 100% + ); + } + `, + ], imports: [ CommonModule, FormsModule, @@ -1203,6 +1321,7 @@ const DEFAULT_VEHICLE_TYPE_OPTIONS: VehicleTypeOption[] = [ DateFieldComponent, UserSearchFieldComponent, SettingsToggleComponent, + MatRippleModule, ], }) export class ParkingRequestFormDetailsComponent @@ -1213,8 +1332,9 @@ export class ParkingRequestFormDetailsComponent private _parking = inject(ParkingService); private _injector = inject(Injector); private _settings = inject(SettingsService); - private _uploads = inject(UploadsService); private _org = inject(OrganisationService); + private _uploads = inject(UploadsService); + private _dialog = inject(MatDialog); private _saved_shift_state: ParkingRequestShiftState | null = null; private readonly _removed_plate_numbers = signal([]); private readonly _selected_shift_duration = signal(0); @@ -1430,9 +1550,9 @@ export class ParkingRequestFormDetailsComponent public readonly shift_type = signal(CUSTOM_SHIFT_ID); public readonly start_time_mins = signal(480); public readonly end_time_mins = signal(1020); + public readonly supporting_doc_names = signal([]); public readonly custom_start_time_mins = signal(480); public readonly custom_end_time_mins = signal(600); - public readonly supporting_doc_names = signal([]); public readonly shift_options = computed(() => { const user_groups = user_group_names(); return this._normaliseShiftOptions(this.shift_options_setting()).filter( @@ -2100,7 +2220,7 @@ export class ParkingRequestFormDetailsComponent const urls = [...(this.model?.().attachments || [])]; const url = urls[index]; if (!url) return; - // this._dialog.open(FullscreenEmbedComponent, { data: url }); + this._dialog.open(FullscreenEmbedComponent, { data: url }); } private _fileNameFromUrl(url: string): string { @@ -2453,10 +2573,12 @@ export class ParkingRequestFormDetailsComponent return; } if (this.has_preset_shifts()) { - const matching_preset = this.shift_options().find( - (_) => - _.start_time === this.start_time_mins() && - _.end_time === this.end_time_mins() % 1440, + const matching_preset = this.shift_options().find((_) => + this._matchesShiftOption( + _, + this.start_time_mins(), + this.end_time_mins(), + ), ); this._applyShift((matching_preset || this.shift_options()[0]).id); return; @@ -2501,10 +2623,8 @@ export class ParkingRequestFormDetailsComponent const preset = (shift.type !== CUSTOM_SHIFT_ID && this.shift_options().find((_) => _.id === shift.type)) || - this.shift_options().find( - (_) => - _.start_time === shift.start_time && - _.end_time === shift.end_time % 1440, + this.shift_options().find((_) => + this._matchesShiftOption(_, shift.start_time, shift.end_time), ); if (preset) { this._applyShift(preset.id); @@ -2524,6 +2644,24 @@ export class ParkingRequestFormDetailsComponent this._applyShift(CUSTOM_SHIFT_ID); } + private _matchesShiftOption( + option: ParkingRequestShiftOption, + start_time: number, + end_time: number, + ) { + return ( + option.start_time === start_time && + this._shiftDuration(option.start_time, option.end_time) === + this._shiftDuration(start_time, end_time) + ); + } + + private _shiftDuration(start_time: number, end_time: number) { + return end_time > start_time + ? end_time - start_time + : 1440 - start_time + end_time; + } + private _syncRequestTypeUser(model: WritableSignal) { const current_user = currentUser(); const selected_user = model().user; diff --git a/apps/workplace/src/app/book/parking-request-flow/parking-request-form.component.ts b/apps/workplace/src/app/book/parking-request-flow/parking-request-form.component.ts index 5d319d4818..179d959bca 100644 --- a/apps/workplace/src/app/book/parking-request-flow/parking-request-form.component.ts +++ b/apps/workplace/src/app/book/parking-request-flow/parking-request-form.component.ts @@ -1,13 +1,14 @@ import { CdkScrollable } from '@angular/cdk/scrolling'; import { Component, computed, inject, OnInit, signal } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; + import { Router } from '@angular/router'; import { BookingFormService, ParkingService } from '@placeos/bookings'; import { AsyncHandler, currentUser, getInvalidSignalFields, + i18n, notifyError, OrganisationService, randomString, @@ -27,103 +28,151 @@ import { ParkingRequestFormDetailsComponent } from './parking-request-form-detai template: `
-
+
- P -
-
-

+ local_parking +
{{ (model().id ? 'APP.WORKPLACE.PARKING_REQUEST_EDIT_HEADER' : 'BOOKINGS.PARKING_REQUEST_TITLE' ) | translate }} -

+
+
+

{{ 'BOOKINGS.PARKING_REQUEST_SUBTITLE' | translate }}

-
- - + + - -
- @if (submission_notes_html()) { -
-

- {{ - 'BOOKINGS.PARKING_SUMMARY_TITLE' | translate - }} -

+ +
+ info +
+ {{ 'BOOKINGS.PARKING_SUMMARY_TITLE' | translate }} +
+
+
+ @if (submission_notes_html()) {
-
- } - -
- @if (loading()) { - - } @else { - - + +
+ + + @if (model().request_type === 'after_hours') { +
+ error + {{ + 'BOOKINGS.PARKING_AFTER_HOURS_WARNING' + | translate + }} +
}
+ + +
+ + +
`, - styles: [``], + styles: [ + ` + :host { + width: 100%; + height: 100%; + } + + .gradient { + background: linear-gradient( + 105deg, + var(--base-200) 0%, + var(--base-200) 50%, + var(--base-100) 100% + ); + } + `, + ], imports: [ CdkScrollable, MatRippleModule, @@ -131,7 +180,6 @@ import { ParkingRequestFormDetailsComponent } from './parking-request-form-detai SanitizePipe, IconComponent, ParkingRequestFormDetailsComponent, - MatProgressSpinnerModule, ], }) export class ParkingRequestFormComponent @@ -145,7 +193,16 @@ export class ParkingRequestFormComponent private _org = inject(OrganisationService); public readonly loading = signal(false); - public readonly show_special_needs = signal(false); + /** Whether the parking record of the user has the special needs flag */ + private readonly _user_special_needs = signal(false); + private readonly _always_show_special_needs = settingSignal( + 'parking.show_special_needs', + false, + ); + /** Whether a `special` request shows the reason and document fields */ + public readonly show_special_needs = computed( + () => this._always_show_special_needs() || this._user_special_needs(), + ); public readonly available_days = settingSignal( 'parking.available_period', 14, @@ -210,7 +267,7 @@ export class ParkingRequestFormComponent '', })); } - this.show_special_needs.set(!!parking_user.special_needs); + this._user_special_needs.set(!!parking_user.special_needs); } } @@ -234,6 +291,18 @@ export class ParkingRequestFormComponent title: m.title || 'Parking Request', plate_number: `${m.plate_number || ''}`.trim(), })); + // The reason field shows only with the special needs details + if ( + this.model().request_type === 'special' && + this.show_special_needs() && + !`${this.model().notes || ''}`.trim() + ) { + return notifyError( + i18n('FORM.INVALID_FIELDS', { + field_list: i18n('BOOKINGS.P2_REASON_FOR_REQUEST'), + }), + ); + } const building = this._org.building; const location = building?.display_name || building?.name || this.model().location; diff --git a/apps/workplace/src/app/book/visitor-flow-new/recent-visitors.ts b/apps/workplace/src/app/book/visitor-flow-new/recent-visitors.ts new file mode 100644 index 0000000000..f67010c10f --- /dev/null +++ b/apps/workplace/src/app/book/visitor-flow-new/recent-visitors.ts @@ -0,0 +1,62 @@ +/** Visitor from the `visitor-invitees` user setting */ +export interface RecentVisitor { + email: string; + name: string; + company: string; + phone: string; + international: boolean; +} + +function cleanValue(value: unknown): string { + if (!value || value === 'null' || value === 'undefined') return ''; + return `${value}`.trim(); +} + +/** Lower-case email address without a `mailto:` prefix or quote characters */ +export function normaliseVisitorEmail(value: unknown): string { + return cleanValue(value) + .replace(/^mailto:/i, '') + .replace(/[<>"']/g, '') + .toLowerCase(); +} + +/** + * Parse the `visitor-invitees` history. The result has the most recent + * visitor first and one visitor for each email address. + * + * An item is `email|name|company|international|phone`. The shared visitor + * forms save only the first four fields, with `international` as `0` or `1`. + * A fourth field with a different value is a phone number from an earlier + * version of this flow. + */ +export function parseRecentVisitors(history: unknown): RecentVisitor[] { + const list: unknown[] = Array.isArray(history) ? history : []; + const visitors = new Map(); + for (let index = list.length - 1; index >= 0; index--) { + const item = list[index]; + if (typeof item !== 'string') continue; + const [email, name, company, flag, phone] = item.split('|'); + const key = normaliseVisitorEmail(email); + if (!key || visitors.has(key)) continue; + const is_flag = !flag || flag === '0' || flag === '1'; + visitors.set(key, { + email: key, + name: cleanValue(name), + company: cleanValue(company), + phone: cleanValue(is_flag ? phone : flag), + international: flag === '1', + }); + } + return [...visitors.values()]; +} + +/** History item for a visitor, in the format that `parseRecentVisitors` reads */ +export function recentVisitorEntry(visitor: Partial): string { + return [ + visitor.email || '', + visitor.name || '', + visitor.company || '', + visitor.international ? '1' : '0', + visitor.phone || '', + ].join('|'); +} diff --git a/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-details.component.ts b/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-details.component.ts new file mode 100644 index 0000000000..e43e3fca3d --- /dev/null +++ b/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-details.component.ts @@ -0,0 +1,478 @@ +import { + Component, + computed, + effect, + inject, + OnInit, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FormField } from '@angular/forms/signals'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatRippleModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { BookingFormService } from '@placeos/bookings'; +import { + currentUser, + OrganisationService, + settingSignal, + SettingsService, + User, +} from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { + DateFieldComponent, + DurationFieldComponent, + HostSelectFieldComponent, + TimeFieldComponent, + UserSearchFieldComponent, +} from '@placeos/form-fields'; +import { addHours, addMinutes } from 'date-fns'; + +type VisitorFormType = 'single' | 'group'; + +@Component({ + selector: 'visitor-flow-details', + template: ` +
+ + @if (!is_edit()) { +
+ + + +
+ {{ + form_type_config()[active_form()].icon + }} + {{ + form_type_config()[active_form()].label + | translate + }} +
+
+ +
+ person + {{ + 'BOOKINGS.VISITOR_SINGLE' | translate + }} +
+
+ +
+ group + {{ + 'BOOKINGS.VISITOR_MULTIPLE' | translate + }} +
+
+
+
+
+ + + } +
+ @if (buildings()?.length > 1) { +
+ + + + @for (bld of buildings(); track bld) { + + {{ bld.display_name || bld.name }} + + } + + +
+ } +
+ + + @if (allow_all_day()) { + + {{ 'COMMON.ALL_DAY' | translate }} + + } +
+ @if (!is_all_day()) { +
+
+ + +
+
+ + +
+
+ } + @if (can_book_for_anyone()) { +
+ + +
+ } @else if (can_book_for_others()) { +
+ + +
+ } +
+ + + + +
+
+
+ `, + styles: [``], + imports: [ + MatRippleModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + DateFieldComponent, + DurationFieldComponent, + TimeFieldComponent, + FormsModule, + FormField, + TranslatePipe, + IconComponent, + UserSearchFieldComponent, + HostSelectFieldComponent, + MatCheckboxModule, + ], +}) +export class VisitorFlowDetailsComponent implements OnInit { + private _booking_form = inject(BookingFormService); + private _org = inject(OrganisationService); + private _settings = inject(SettingsService); + + private _options = this._booking_form.options; + + private _sync_group = effect(() => { + const is_group = this._options()?.group === true; + if (is_group && this.active_form() !== 'group') { + this.active_form.set('group'); + } + }); + + public readonly active_form = signal('single'); + public readonly form_type_config = signal({ + single: { icon: 'person', label: 'BOOKINGS.VISITOR_SINGLE' }, + group: { icon: 'group', label: 'BOOKINGS.VISITOR_MULTIPLE' }, + }); + + public readonly is_edit = computed(() => !!this.model()?.id); + public readonly selected_building_id = computed(() => { + return this._resolveSelectedBuildingId(this.model()?.zones || []); + }); + + public readonly duration_step = computed( + () => + settingSignal('visitors.duration_step')() || + settingSignal('bookings.duration_step', 15)(), + ); + public readonly min_duration = computed( + () => + settingSignal('visitors.min_duration')() || + settingSignal('bookings.min_duration', 30)(), + ); + public readonly max_duration = computed( + () => + settingSignal('visitors.max_duration')() || + settingSignal('bookings.max_duration', 180)(), + ); + public readonly custom_duration_options = computed( + () => + settingSignal('visitors.custom_duration_options')() || + settingSignal('bookings.custom_duration_options', [])(), + ); + public readonly effective_min_duration = computed(() => + Math.min(this.min_duration(), ...this.custom_duration_options()), + ); + + public readonly bookable_hours = settingSignal< + { start: number; end: number } | undefined + >('visitors.bookable_hours', undefined); + + private readonly _fallback_bookable_hours = settingSignal< + { start: number; end: number } | undefined + >('bookings.bookable_hours', undefined); + + public readonly effective_bookable_hours = computed( + () => this.bookable_hours() ?? this._fallback_bookable_hours(), + ); + + public readonly allow_all_day = settingSignal( + 'visitors.allow_all_day', + false, + ); + public readonly is_all_day = computed( + () => this.allow_all_day() && !!this.model()?.all_day, + ); + public readonly is_edit_in_progress = computed(() => { + if (!this.is_edit()) return false; + const booking_date = Number(this.model()?.date || 0); + if (!booking_date) return false; + const duration = Number(this.model()?.duration || 0); + const end_date = this.is_all_day() + ? addHours(booking_date, 24).valueOf() + : addMinutes(booking_date, duration).valueOf(); + return Date.now() >= booking_date && Date.now() < end_date; + }); + + public readonly can_book_for_others = computed(() => { + return ( + settingSignal('visitors.can_book_for_others')() ?? + settingSignal('bookings.can_book_for_others')() + ); + }); + + public readonly can_book_for_anyone = computed(() => { + return ( + settingSignal('visitors.can_book_for_anyone')() ?? + settingSignal('bookings.can_book_for_anyone')() + ); + }); + + public readonly use_24hr = settingSignal('use_24_hour_time', false); + public readonly buildings = this._org.active_buildings; + + public get form() { + return this._booking_form.form; + } + + public get model() { + return this._booking_form.model; + } + + public get timezone() { + return this._settings.get('app.bookings.use_building_timezone') || + this._settings.get('app.visitors.use_building_timezone') + ? this._org.building.timezone + : ''; + } + + public ngOnInit() { + const value = this.model(); + if (value.all_day && !this.allow_all_day()) { + this.model.update((m) => ({ ...m, all_day: false })); + } + const has_assets = Array.isArray(value.assets) && value.assets.length; + const is_group = + !!value.asset_id && + (value.asset_id === 'multiple@place.tech' || has_assets > 1); + this.active_form.set(is_group ? 'group' : 'single'); + this._booking_form.setOptions({ group: is_group }); + const zones = this.model()?.zones || []; + if (!this.model().id) + this.model.update((m) => ({ ...m, title: 'Visit' })); + if (!this.model()?.user_email) { + this.model.update((m) => ({ ...m, user: currentUser() })); + } + if (!zones.length && this._org.building?.id) { + const default_zones = [ + this._org.organisation?.id, + this._org.region?.id, + this._org.building.id, + ].filter((_) => _); + this.model.update((m) => ({ ...m, zones: default_zones })); + return; + } + // Editing a booking from another building: match the active building to + // it so saving doesn't tag the booking with the previously active one. + const building = this._org.buildings.find( + (bld) => bld.id === this.selected_building_id(), + ); + if (building && building.id !== this._org.building?.id) { + this._org.building = building; + } + } + + public setActiveForm(form: VisitorFormType) { + if (this.is_edit() || this.active_form() === form) return; + this.active_form.set(form); + this._booking_form.setOptions({ group: form === 'group' }); + const value = this.model(); + + if (form === 'single') { + const [visitor] = (value.assets || []) as (User & { + company?: string; + })[]; + const use_selected_visitor = + !!visitor?.email && + (!value.asset_id || value.asset_id === 'multiple@place.tech'); + this.model.update((m) => ({ + ...m, + user: currentUser(), + assets: [], + asset_id: use_selected_visitor + ? visitor.email + : value.asset_id === 'multiple@place.tech' + ? '' + : value.asset_id, + asset_name: use_selected_visitor + ? visitor.name || visitor.email + : value.asset_name || '', + company: use_selected_visitor + ? visitor.company || visitor.organisation || '' + : value.company || '', + phone: use_selected_visitor + ? visitor.phone || value.phone || '' + : value.phone || '', + })); + } else { + const assets = [...(value.assets || [])]; + if (value.asset_id && value.asset_id !== 'multiple@place.tech') { + assets.unshift( + new User({ + name: value.asset_name || value.asset_id, + email: value.asset_id, + organisation: value.company || '', + phone: value.phone || '', + }), + ); + } + const unique_assets = assets.filter( + (item, index, list) => + !!item?.email && + list.findIndex((_) => _.email === item.email) === index, + ); + this.model.update((m) => ({ + ...m, + user: currentUser(), + asset_id: 'multiple@place.tech', + assets: unique_assets, + })); + } + } + + public setBuilding(building_id: string) { + if (!building_id) { + this.model.update((m) => ({ ...m, zones: [] })); + return; + } + const building = this._org.find(building_id); + // Saving merges the active building's zones into the booking, so the + // active building has to move too or the booking keeps the old one. + if (building && building.id !== this._org.building?.id) { + this._org.building = building; + } + const zones = [ + this._org.organisation?.id, + building?.parent_id, + building_id, + ].filter((_) => _); + this.model.update((m) => ({ ...m, zones })); + } + + private _resolveSelectedBuildingId(zone_list: string[]) { + const level = this._org.levelWithID(zone_list); + const building = + this._org.buildings.find((bld) => zone_list.includes(bld.id)) || + this._org.buildings.find((bld) => level?.parent_id === bld.id); + return building?.id || this._org.building?.id || zone_list[0] || ''; + } +} diff --git a/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-invites.component.ts b/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-invites.component.ts new file mode 100644 index 0000000000..2dafc3eac5 --- /dev/null +++ b/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-invites.component.ts @@ -0,0 +1,338 @@ +import { + Component, + computed, + inject, + Injector, + OnInit, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FormField } from '@angular/forms/signals'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatRippleModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { BookingFormService } from '@placeos/bookings'; +import { + AsyncHandler, + onFieldChange, + settingSignal, + SettingsService, +} from '@placeos/common'; +import { TranslatePipe } from '@placeos/components'; +import { UserListFieldComponent } from '@placeos/form-fields'; +import { + normaliseVisitorEmail, + parseRecentVisitors, + RecentVisitor, + recentVisitorEntry, +} from './recent-visitors'; + +@Component({ + selector: 'visitor-flow-invites', + template: ` +
+
+ @if (is_single()) { +
+
+ + + + + + @for (item of filtered_visitors(); track item) { + +
+
{{ item.name }}
+
+ {{ item.email }} + {{ + item.company + ? '| ' + item.company + : '' + }} +
+
+
+ } +
+
+
+ + + + + {{ 'FORM.EMAIL_REQUIRED' | translate }} + + + + @for (item of filtered_visitors(); track item) { + +
+
{{ item.name }}
+
+ {{ item.email }} + {{ + item.company + ? '| ' + item.company + : '' + }} +
+
+
+ } +
+
+
+
+
+ + + + +
+
+ + + + +
+
+ } @else { +
+ + +
+ } + @if (allow_pass_number()) { +
+ + + + +
+ } +
+
+ `, + styles: [``], + imports: [ + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatAutocompleteModule, + MatRippleModule, + UserListFieldComponent, + TranslatePipe, + FormField, + FormsModule, + ], +}) +export class VisitorFlowInvitesComponent + extends AsyncHandler + implements OnInit +{ + private _booking_form = inject(BookingFormService); + private _settings = inject(SettingsService); + private _injector = inject(Injector); + + public readonly search_term = signal(''); + public readonly visitors = signal([]); + public readonly options = this._booking_form.options; + + public readonly is_single = computed(() => { + // The toggle controls the mode - check if group option is set + const is_group_mode = this.options()?.group === true; + // Return true if NOT in group mode (i.e., in single mode) + return !is_group_mode; + }); + + public readonly filtered_visitors = computed(() => { + const s = this.search_term().toLowerCase(); + return this.visitors().filter( + ({ email, name, company }) => + email.toLowerCase().includes(s) || + name.toLowerCase().includes(s) || + company.toLowerCase().includes(s), + ); + }); + + public readonly allow_pass_number = settingSignal( + 'visitors.allow_pass_number', + false, + ); + + public readonly multiple = settingSignal( + 'bookings.multiple_visitors', + false, + ); + + public get form() { + return this._booking_form.form; + } + + public get model() { + return this._booking_form.model; + } + + public ngOnInit() { + // The booking form schema already declares the required + email + // validators for `asset_id` on visitor bookings, so no manual + // validator wiring is needed here. + + // Load previous visitors from settings + this.visitors.set( + parseRecentVisitors( + this._settings.get('visitor-invitees'), + ).reverse(), + ); + + // Initialize search term + this.search_term.set(''); + + // Track form changes for autocomplete + const email_handle = onFieldChange( + this.model, + (m) => m.asset_id, + (_) => this.search_term.set(_ || ''), + this._injector, + ); + this.subscription('email', () => email_handle.destroy()); + const name_handle = onFieldChange( + this.model, + (m) => m.asset_name, + (_) => this.search_term.set(_ || ''), + this._injector, + ); + this.subscription('name', () => name_handle.destroy()); + + if ( + !this.model().id && + !this.model().title && + !this.model().description + ) { + this.model.update((m) => ({ + ...m, + title: 'Visit', + description: 'Visit', + })); + } + + // Initialize based on current mode + const is_group = this.options()?.group === true; + if (is_group && !this.model().asset_id) { + this.model.update((m) => ({ + ...m, + asset_id: 'multiple@place.tech', + })); + } + } + + public setVisitor(item: Partial) { + const asset_id = normaliseVisitorEmail(item?.email); + if (!asset_id) return; + const visitor: Partial = { + ...item, + email: asset_id, + name: item.name || asset_id, + company: item.company || '', + }; + + this.model.update((m) => ({ + ...m, + asset_id, + asset_name: visitor.name, + company: visitor.company, + phone: item.phone, + })); + + // Save to visitor history + const old_visitors = parseRecentVisitors( + this._settings.get('visitor-invitees'), + ) + .filter((_) => _.email !== asset_id) + .reverse(); + this._settings.saveUserSetting( + 'visitor-invitees', + [...old_visitors, visitor].map(recentVisitorEntry), + ); + } +} diff --git a/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-recent.component.ts b/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-recent.component.ts new file mode 100644 index 0000000000..5fb4bc33ee --- /dev/null +++ b/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-recent.component.ts @@ -0,0 +1,241 @@ +import { SlicePipe } from '@angular/common'; +import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { BookingFormService } from '@placeos/bookings'; +import { SettingsService } from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { parseRecentVisitors, RecentVisitor } from './recent-visitors'; + +@Component({ + selector: 'visitor-flow-recent', + template: ` + @if (!is_edit()) { +
+
+
+
+
Quick Action
+
+
+ history +
Recent Visitors
+
+
+
+

+ {{ 'BOOKINGS.VISITOR_RECENT' | translate }} +

+

+ @if (is_single()) { + Quickly re-invite visitors you've previously booked. + } @else { + Select visitors to invite. Click to toggle. + } +

+
+ @if (recent_visitors().length > 0) { +
+ @for ( + visitor of recent_visitors() | slice: 0 : 4; + track visitor.email + ) { + + } +
+ } @else { +
+ person_search +
+
+ No recent visitors +
+
+ Your visitor history will appear here +
+
+
+ } +
+ } + `, + styles: [ + ` + .bg-grad { + background: linear-gradient( + 105deg, + var(--brand-100) 0%, + var(--brand-200) 100% + ); + } + `, + ], + imports: [IconComponent, TranslatePipe, SlicePipe], +}) +export class VisitorFlowRecentComponent implements OnInit { + private _booking_form = inject(BookingFormService); + private _settings = inject(SettingsService); + + public readonly visitors = signal([]); + public readonly options = this._booking_form.options; + + private get _model() { + return this._booking_form.model; + } + public readonly is_edit = computed(() => !!this._model()?.id); + + public readonly is_single = computed(() => { + const is_group_mode = this.options()?.group === true; + return !is_group_mode; + }); + + public readonly recent_visitors = computed(() => { + return this.visitors().slice(0, 5).map((visitor) => { + return this.enrichVisitorData(visitor); + }); + }); + + public readonly selected_visitors = computed(() => { + const assets = this._model()?.assets || []; + return new Set(assets.map((a) => a.email)); + }); + + public ngOnInit() { + // Load visitor history + this.visitors.set( + parseRecentVisitors(this._settings.get('visitor-invitees')), + ); + } + + public isVisitorSelected(visitor: RecentVisitor): boolean { + if (this.is_single()) { + return this._model()?.asset_id === visitor.email; + } + return this.selected_visitors().has(visitor.email); + } + + private extractNameFromEmail(email: string): string { + const localPart = email.split('@')[0]; + // Replace dots, underscores, hyphens with spaces + const name = localPart.replace(/[._-]/g, ' '); + // Capitalize each word + return name + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + private extractCompanyFromEmail(email: string): string { + const domain = email.split('@')[1]; + if (!domain) return ''; + // Get the domain name without TLD + const companyName = domain.split('.')[0]; + // Capitalize first letter + return companyName.charAt(0).toUpperCase() + companyName.slice(1); + } + + private enrichVisitorData(visitor: RecentVisitor): RecentVisitor { + const enriched = { ...visitor }; + + // Extract name from email if not provided, null, or empty + if ( + (!enriched.name || + enriched.name === 'null' || + enriched.name === 'undefined') && + enriched.email + ) { + enriched.name = this.extractNameFromEmail(enriched.email); + } + + // Extract company from email domain if not provided, null, or empty + if ( + (!enriched.company || + enriched.company === 'null' || + enriched.company === 'undefined') && + enriched.email + ) { + enriched.company = this.extractCompanyFromEmail(enriched.email); + } + + return enriched; + } + + public selectVisitor(visitor: RecentVisitor) { + const is_group = this.options()?.group === true; + const enriched = this.enrichVisitorData(visitor); + + if (is_group) { + // Toggle visitor in assets array + const current_assets = [...(this._model()?.assets || [])]; + const index = current_assets.findIndex( + (a) => a.email === visitor.email, + ); + + if (index >= 0) { + // Remove visitor + current_assets.splice(index, 1); + } else { + // Add visitor + current_assets.push({ + email: enriched.email, + name: enriched.name, + company: enriched.company, + phone: enriched.phone, + }); + } + + this._model.update((m) => ({ + ...m, + assets: current_assets, + })); + } else { + // Single mode - set as the visitor + this._model.update((m) => ({ + ...m, + asset_id: enriched.email, + asset_name: enriched.name, + company: enriched.company, + phone: enriched.phone, + })); + } + } +} diff --git a/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-success.component.ts b/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-success.component.ts new file mode 100644 index 0000000000..0ff3fa63aa --- /dev/null +++ b/apps/workplace/src/app/book/visitor-flow-new/visitor-flow-success.component.ts @@ -0,0 +1,231 @@ +import { DatePipe } from '@angular/common'; +import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { Router } from '@angular/router'; +import { MatRippleModule } from '@angular/material/core'; +import { + Booking, + OrganisationService, + settingSignal, + SettingsService, + User, +} from '@placeos/common'; +import { + IconComponent, + SanitizePipe, + TranslatePipe, +} from '@placeos/components'; +import { + CalEvent, + generateCalendarFileLink, + generateGoogleCalendarLink, + generateMicrosoftCalendarLink, +} from '@placeos/events'; +import { BookingFormService } from '@placeos/bookings'; + +@Component({ + selector: 'visitor-flow-success', + template: ` +
+
+

+ {{ + (multiple() + ? 'BOOKINGS.VISITOR_SENT_MULTIPLE' + : 'BOOKINGS.VISITOR_SENT_SINGLE' + ) + | translate + : { + name: + last_success()?.asset_name || + last_success()?.asset_id, + count: count(), + } + }} +

+ +

+ {{ + (multiple() && count() > 1 + ? 'BOOKINGS.VISITOR_SENT_MSG_MULTIPLE' + : 'BOOKINGS.VISITOR_SENT_MSG' + ) + | translate + : { + location: + building()?.display_name || + building()?.name, + date: + last_success()?.date | date: 'mediumDate', + time: + last_success()?.date | date: time_format, + } + }} +

+ @if (show_links()) { + + } +
+
+
+ + +
+
+
+ `, + styles: [ + ` + :host { + width: 100%; + height: 100%; + } + `, + ], + imports: [ + MatRippleModule, + IconComponent, + TranslatePipe, + DatePipe, + SanitizePipe, + ], +}) +export class VisitorFlowSuccessComponent implements OnInit { + private _form = inject(BookingFormService); + private _org = inject(OrganisationService); + private _settings = inject(SettingsService); + private _router = inject(Router); + + public readonly multiple = settingSignal( + 'bookings.multiple_visitors', + false, + ); + public readonly show_links = settingSignal( + 'visitors.show_calendar_links', + false, + ); + public readonly last_success = signal(null); + public readonly count = signal(1); + + public readonly building = computed(() => + !this.last_success() + ? this._org.building + : this._org.buildings.find((b) => + this.last_success().zones.includes(b.id), + ), + ); + + public readonly cal_event = computed(() => { + if (!this.last_success()) return null; + const event: CalEvent = { + ...this.last_success(), + host: this.last_success().user_email, + organiser: new User({ + name: this.last_success().user_name, + email: this.last_success().user_email, + }), + attendees: this.last_success().attendees.map((_) => _.email), + body: this.last_success().description, + location: + this.building()?.display_name || this.building()?.name, + }; + event.attendees.push(this.last_success().asset_id); + return event; + }); + + public readonly outlook_link = computed(() => + this.cal_event() ? generateMicrosoftCalendarLink(this.cal_event()) : '', + ); + + public readonly google_link = computed(() => + this.cal_event() ? generateGoogleCalendarLink(this.cal_event()) : '', + ); + + public readonly ical_link = computed(() => + this.cal_event() ? generateCalendarFileLink(this.cal_event()) : '', + ); + + public get time_format() { + return this._settings.time_format; + } + + public async ngOnInit() { + await this._org.waitUntilInitialised(); + const last = this._form.last_success; + if (last) { + this.last_success.set(last); + } + this.count.set(this._form.last_count || 1); + } + + public done() { + this._router.navigate(['/']); + } + + public bookAnother() { + this._form.clearForm(); + this._router.navigate(['/book/visitor/form']); + } +} diff --git a/apps/workplace/src/app/book/visitor-flow-new/visitor-flow.component.ts b/apps/workplace/src/app/book/visitor-flow-new/visitor-flow.component.ts new file mode 100644 index 0000000000..4730dfc5bd --- /dev/null +++ b/apps/workplace/src/app/book/visitor-flow-new/visitor-flow.component.ts @@ -0,0 +1,380 @@ +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BookingFlowView, BookingFormService } from '@placeos/bookings'; +import { + AsyncHandler, + Booking, + getInvalidSignalFields, + i18n, + notifyError, + notifySuccess, + SettingsService, + User, +} from '@placeos/common'; +import { + IconComponent, + openConfirmModal, + TranslatePipe, +} from '@placeos/components'; +import { recentVisitorEntry } from './recent-visitors'; +import { VisitorFlowDetailsComponent } from './visitor-flow-details.component'; +import { VisitorFlowInvitesComponent } from './visitor-flow-invites.component'; +import { VisitorFlowRecentComponent } from './visitor-flow-recent.component'; +import { VisitorFlowSuccessComponent } from './visitor-flow-success.component'; + +@Component({ + selector: 'visitor-flow-new', + template: ` + @if (view() !== 'success') { +
+
+ +
+
+ info +
+ {{ visit_heading() | translate }} +
+
+ +
+ info +
+ {{ 'BOOKINGS.VISITOR_DETAILS_HEADER' | translate }} +
+
+ +
+
+
+ +
+
+
+ } @else { + + } + `, + styles: [ + ` + :host { + width: 100%; + height: 100%; + } + + .gradient { + background: linear-gradient( + 105deg, + var(--base-200) 0%, + var(--base-200) 50%, + var(--base-100) 100% + ); + } + + .bg-grad { + background: linear-gradient( + 105deg, + var(--brand-400) 0%, + var(--brand-500) 100% + ); + } + `, + ], + imports: [ + CdkScrollable, + IconComponent, + TranslatePipe, + MatRippleModule, + VisitorFlowDetailsComponent, + VisitorFlowInvitesComponent, + VisitorFlowRecentComponent, + VisitorFlowSuccessComponent, + ], +}) +export class VisitorFlowNewComponent extends AsyncHandler implements OnInit { + private _booking_form = inject(BookingFormService); + private _settings = inject(SettingsService); + private _router = inject(Router); + private _route = inject(ActivatedRoute); + private _dialog = inject(MatDialog); + private _existing_siblings: Booking[] = []; + + public readonly view = this._booking_form.view; + public readonly loading = signal(false); + + private get _form() { + return this._booking_form.form; + } + private get _model() { + return this._booking_form.model; + } + + /** Whether the user selected to invite multiple visitors */ + public readonly is_multiple = computed( + () => this._booking_form.options()?.group === true, + ); + public readonly visit_heading = computed(() => + this._model()?.id + ? 'BOOKINGS.EDIT_VISITOR_DETAILS' + : 'BOOKINGS.VISITOR_TIME_HEADER', + ); + + public ngOnInit() { + const { id, booking_type } = this._model(); + if (!id || booking_type !== 'visitor') + this._booking_form.newForm('visitor'); + this._model.update((m) => ({ ...m, booking_type: 'visitor' })); + this._booking_form.setOptions({ type: 'visitor' }); + this.subscription( + 'route.params', + this._route.paramMap.subscribe((param) => { + if (param.has('step')) + this._booking_form.setView( + param.get('step') as BookingFlowView, + ); + }), + ); + this._loadGroupVisitors(); + } + + private async _loadGroupVisitors() { + const value = this._model(); + if (!value.id) return; + const booking = this._booking_form.booking; + const is_group = + !!booking?.parent_id || + !!booking?.group || + !!booking?.extension_data?.group_members?.length; + if (!is_group) return; + const siblings = + await this._booking_form.loadGroupSiblings(booking); + if (!siblings?.length) return; + this._existing_siblings = siblings; + const visitors = siblings.map( + (s) => + new User({ + name: s.asset_name, + email: s.asset_id, + organisation: s.extension_data?.company, + phone: s.extension_data?.phone, + }), + ); + this._model.update((m) => ({ + ...m, + assets: visitors, + asset_id: 'multiple@place.tech', + })); + this._booking_form.setOptions({ group: true }); + } + + public async confirmBooking() { + this._form().markAsTouched(); + + const is_multiple = this.is_multiple(); + const visitor_reason = + this._model()?.title || this._model()?.description || 'Visit'; + + // Validate form + if (!this._form().valid()) { + return notifyError( + i18n('FORM.INVALID_FIELDS', { + field_list: getInvalidSignalFields(this._form, this._model) + .join(', ') + .replace('asset_id', i18n('RESOURCE.VISITOR')), + }), + ); + } + + // Additional validation for multiple visitors + if (is_multiple && !this._model()?.assets?.length) { + return notifyError(i18n('BOOKINGS.VISITOR_REQUIRED')); + } + + this.loading.set(true); + try { + const asset_name = this._model()?.asset_name; + this._model.update((m) => ({ + ...m, + title: visitor_reason, + description: visitor_reason, + })); + this._booking_form.last_count = is_multiple + ? this._model()?.assets?.length || 1 + : 1; + this._saveRecentVisitors(is_multiple); + await (is_multiple ? this._bookForMany() : this._bookForOne()); + const name = is_multiple ? i18n('RESOURCE.VISITORS') : asset_name; + notifySuccess( + i18n( + is_multiple + ? 'BOOKINGS.VISITOR_SENT_MULTIPLE' + : 'BOOKINGS.VISITOR_SENT_SINGLE', + { name, count: this._booking_form.last_count }, + ), + ); + this._router.navigate(['/book/visitor/success']); + } catch (e) { + notifyError( + typeof e === 'string' + ? e + : i18n(`BOOKINGS.VISITOR_BOOKING_ERROR`), + ); + } finally { + this.loading.set(false); + } + } + + public async canDeactivate() { + const value = this._model(); + if (!value.id || !this._form().dirty()) return true; + const result = await openConfirmModal( + { + title: i18n('APP.CONCIERGE.UNSAVED_CHANGES_TITLE'), + content: i18n('APP.CONCIERGE.UNSAVED_CHANGES_MSG'), + confirm_text: i18n('APP.CONCIERGE.UNSAVED_CHANGES_DISCARD'), + icon: { content: 'warning' }, + }, + this._dialog, + ); + if (result.reason !== 'done') return false; + result.close(); + this._booking_form.clearForm(); + return true; + } + + private _saveRecentVisitors(is_multiple: boolean) { + const old_visitors: string[] = + this._settings.get('visitor-invitees') || []; + const value = this._model(); + if (is_multiple && value.assets?.length) { + const assets: (Partial & { company?: string })[] = + value.assets; + const emails = new Set(assets.map((a) => a.email).filter(Boolean)); + this._settings.saveUserSetting('visitor-invitees', [ + ...old_visitors.filter( + (v) => !emails.has(`${v}`.split('|')[0]), + ), + ...assets + .filter((a) => !!a.email) + .map((a) => + recentVisitorEntry({ + email: a.email, + name: a.name, + company: a.organisation || a.company, + phone: a.phone, + }), + ), + ]); + } else { + const { asset_id, asset_name, company, phone, international } = + value; + this._settings.saveUserSetting('visitor-invitees', [ + ...old_visitors.filter( + (v) => `${v}`.split('|')[0] !== asset_id, + ), + recentVisitorEntry({ + email: asset_id, + name: asset_name, + company, + phone, + international, + }), + ]); + } + } + + private async _bookForOne() { + const value = this._model(); + this._model.update((m) => ({ + ...m, + name: value.asset_name, + attendees: [ + new User({ + name: value.asset_name, + email: value.asset_id, + organisation: value.company, + phone: value.phone, + }), + ], + })); + await this._booking_form.postForm(); + } + + private async _bookForMany() { + const value = this._model(); + const assets: User[] = value.assets || []; + const visitor_members = assets + .filter((_) => !!_.email) + .map( + (user) => + new User({ + ...user, + name: user.name || user.email, + }), + ); + this._booking_form.setOptions({ + type: 'visitor', + group: true, + members: visitor_members, + }); + if (value.id) { + let existing_siblings = this._existing_siblings; + if (!existing_siblings.length) { + existing_siblings = + await this._booking_form.loadGroupSiblings( + new Booking(value as any), + ); + } + if (!existing_siblings.length) { + existing_siblings = [new Booking(value as any)]; + } + this._existing_siblings = existing_siblings; + await this._booking_form.editFormForGroup(existing_siblings); + } else { + await this._booking_form.postFormForVisitorGroup(); + } + } +} diff --git a/apps/workplace/src/app/components/footer-menu.component.ts b/apps/workplace/src/app/components/footer-menu.component.ts index 3bb6809423..e05c933468 100644 --- a/apps/workplace/src/app/components/footer-menu.component.ts +++ b/apps/workplace/src/app/components/footer-menu.component.ts @@ -1,4 +1,5 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, computed, inject, model, signal } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; import { RouterModule } from '@angular/router'; import { AsyncHandler, @@ -8,282 +9,174 @@ import { import { IconComponent, TranslatePipe } from '@placeos/components'; import type { TopMenuEmbedItem } from './top-menu.component'; +interface FooterMenuItem { + id: string; + name: string; + icon: string; + route: string[]; +} + +const FEATURE_MENU_ITEMS: FooterMenuItem[] = [ + { + id: 'spaces', + name: 'APP.WORKPLACE.MENU_ROOMS', + icon: 'meeting_room', + route: ['/book', 'meeting'], + }, + { + id: 'desks', + name: 'APP.WORKPLACE.MENU_DESKS', + icon: 'desk', + route: ['/book', 'desk'], + }, + { + id: 'parking', + name: 'APP.WORKPLACE.MENU_PARKING', + icon: 'directions_car', + route: ['/book', 'parking'], + }, + { + id: 'parking-requests', + name: 'APP.WORKPLACE.MENU_PARKING_REQUESTS', + icon: 'local_parking', + route: ['/book', 'parking-request'], + }, + { + id: 'visitor-invite', + name: 'APP.WORKPLACE.MENU_VISITORS', + icon: 'person_add', + route: ['/book', 'visitor'], + }, + { + id: 'schedule', + name: 'APP.WORKPLACE.MENU_SCHEDULE', + icon: 'today', + route: ['/your-bookings'], + }, + { + id: 'group-events', + name: 'APP.WORKPLACE.MENU_EVENTS', + icon: 'local_activity', + route: ['/group-events'], + }, + { + id: 'lockers', + name: 'APP.WORKPLACE.MENU_LOCKERS', + icon: 'lock', + route: ['/book', 'locker'], + }, + { + id: 'control', + name: 'APP.WORKPLACE.MENU_CONTROL', + icon: 'remote_gen', + route: ['/control'], + }, + { + id: 'deals-n-offers', + name: 'APP.WORKPLACE.MENU_DEALS', + icon: 'confirmation_number', + route: ['/deals-n-offers'], + }, + { + id: 'team-schedule', + name: 'APP.WORKPLACE.MENU_TEAM_SCHEDULE', + icon: 'groups', + route: ['/team-schedule'], + }, +]; + @Component({ selector: 'footer-menu', template: ` @if (show_book_items() && footer_item_count() > 1) {
-
- @if (features().includes('spaces')) { - - } - @if (features().includes('desks')) { - - } - @if (features().includes('parking')) { - - } - @if (features().includes('parking-requests')) { - - } - @if (features().includes('visitor-invite')) { - +
+ {{ item.name | translate }} +
+ } - @if (features().includes('schedule')) { - - } - @if (features().includes('events')) { - - } - @for (item of menu_embeds(); track item.id) { - + } }
} @if (footer_item_count() > 1) {
home {{ 'APP.WORKPLACE.MENU_HOME' | translate }} -
+ +
+ `, + styles: [``], + imports: [IconComponent, MatRippleModule, SafePipe, MatDialogModule], +}) +export class FullscreenEmbedComponent { + private _data: string = inject(MAT_DIALOG_DATA); + public readonly embed_url = signal(this._data); +} diff --git a/apps/workplace/src/app/components/sidebar-embed.component.ts b/apps/workplace/src/app/components/sidebar-embed.component.ts new file mode 100644 index 0000000000..401718c383 --- /dev/null +++ b/apps/workplace/src/app/components/sidebar-embed.component.ts @@ -0,0 +1,69 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { IconComponent, SafePipe } from '@placeos/components'; + +interface SidebarEmbedData { + url: string; + side: 'left' | 'right'; +} + +@Component({ + selector: 'sidebar-embed', + template: ` + + + `, + styles: [ + ` + :host { + display: block; + height: 100%; + width: 100%; + } + + .sidebar-embed__container { + overflow: hidden; + } + + .sidebar-embed__close { + left: 0.5rem; + } + + @media (min-width: 640px) { + .sidebar-embed__close--left { + right: calc(28rem + 0.5rem); + left: auto; + } + + .sidebar-embed__close--right { + left: calc(28rem + 0.5rem); + } + } + `, + ], + imports: [IconComponent, MatRippleModule, SafePipe, MatDialogModule], +}) +export class SidebarEmbedComponent { + private _data: SidebarEmbedData = inject(MAT_DIALOG_DATA); + public readonly embed_url = signal(this._data.url); + public readonly close_button_side = computed(() => + this._data.side === 'right' ? 'left' : 'right', + ); +} diff --git a/apps/workplace/src/app/components/top-menu.component.ts b/apps/workplace/src/app/components/top-menu.component.ts index 30327ba9d6..3bbec5d342 100644 --- a/apps/workplace/src/app/components/top-menu.component.ts +++ b/apps/workplace/src/app/components/top-menu.component.ts @@ -323,18 +323,18 @@ export class TopMenuComponent { icon: 'home', name: i18n('APP.WORKPLACE.MENU_HOME'), }, - { - id: 'spaces', - route: '/book/meeting', - icon: 'meeting_room', - name: i18n('APP.WORKPLACE.MENU_ROOMS'), - }, { id: 'desks', route: '/book/desk', icon: 'desk', name: i18n('APP.WORKPLACE.MENU_DESKS'), }, + { + id: 'spaces', + route: '/book/meeting', + icon: 'meeting_room', + name: i18n('APP.WORKPLACE.MENU_ROOMS'), + }, { id: 'lockers', route: '/book/locker', @@ -389,6 +389,12 @@ export class TopMenuComponent { icon: 'confirmation_number', name: i18n('APP.WORKPLACE.MENU_DEALS'), }, + { + id: 'team-schedule', + route: '/team-schedule', + icon: 'groups', + name: i18n('APP.WORKPLACE.MENU_TEAM_SCHEDULE'), + }, ...this.menu_embeds() .filter((item) => item?.id && item?.name && item?.url) .map((item) => ({ diff --git a/apps/workplace/src/app/components/topbar.component.ts b/apps/workplace/src/app/components/topbar.component.ts index 209e586a4b..8e22dadb50 100644 --- a/apps/workplace/src/app/components/topbar.component.ts +++ b/apps/workplace/src/app/components/topbar.component.ts @@ -1,13 +1,14 @@ -import { Component, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; +import { Component, computed, inject, viewChild } from '@angular/core'; import { RouterModule } from '@angular/router'; import { - currentUser, OrganisationService, + settingSignal, SettingsService, + userSignal, } from '@placeos/common'; import { AuthenticatedImageDirective, + UserAvatarComponent, UserControlsSidebarComponent, } from '@placeos/components'; import { GlobalSearchComponent } from './global-search.component'; @@ -32,7 +33,7 @@ const EMPTY = []; class="h-10 sm:block" [class.hidden]="title" alt="Logo" - [source]="logo?.src || logo" + [source]="$any(logo())?.src || logo()" /> @if (title) { {{ title }} @@ -47,7 +48,9 @@ const EMPTY = []; @if (search) { } - + + +
`, @@ -59,11 +62,11 @@ const EMPTY = []; `, ], imports: [ - MatRippleModule, GlobalSearchComponent, TopMenuComponent, AuthenticatedImageDirective, RouterModule, + UserAvatarComponent, UserControlsSidebarComponent, ], }) @@ -72,14 +75,15 @@ export class TopbarComponent { private _org = inject(OrganisationService); public show_menu: boolean; + public readonly user = userSignal(); + public readonly user_controls = viewChild(UserControlsSidebarComponent); + + public readonly logo = computed(() => { + return this._settings.theme_signal() === 'dark' + ? settingSignal('logo_dark', '')() + : settingSignal('logo_light', '')(); + }); - public get logo() { - return ( - (this._settings.theme === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ); - } /** Text to display for page title */ public get title(): string { return this._settings.value('page_title'); @@ -94,10 +98,6 @@ export class TopbarComponent { return !!this._settings.get('app.new_features'); } - public get user() { - return currentUser(); - } - public get features(): string[] { return this._settings.get('app.features') || EMPTY; } diff --git a/apps/workplace/src/app/components/virtual-concierge-button.component.ts b/apps/workplace/src/app/components/virtual-concierge-button.component.ts new file mode 100644 index 0000000000..5b495eb3d2 --- /dev/null +++ b/apps/workplace/src/app/components/virtual-concierge-button.component.ts @@ -0,0 +1,88 @@ +import { Component, computed, inject } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { settingSignal } from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { FullscreenEmbedComponent } from './fullscreen-embed.component'; +import { SidebarEmbedComponent } from './sidebar-embed.component'; + +@Component({ + selector: 'virtual-concierge-button', + template: ` + @if (virtual_concierge_url()) { + + } + `, + styles: [``], + imports: [MatRippleModule, IconComponent, TranslatePipe], +}) +export class VirtualConciergeButtonComponent { + private readonly _dialog = inject(MatDialog); + + private readonly _configured_url = settingSignal( + 'virtual_concierge.url', + '', + ); + private readonly _legacy_url = settingSignal('virtual_concierge_url', ''); + + public readonly virtual_concierge_url = computed( + () => this._configured_url() || this._legacy_url(), + ); + /** URL of the image for the button. A default icon shows when empty */ + public readonly virtual_concierge_icon = settingSignal( + 'virtual_concierge.icon', + '', + ); + public readonly virtual_concierge_display = settingSignal< + 'fullscreen' | 'sidebar' + >('virtual_concierge.display', 'fullscreen'); + public readonly virtual_concierge_side = settingSignal<'left' | 'right'>( + 'virtual_concierge.side', + 'left', + ); + + public viewVirtualConcierge() { + const url = this.virtual_concierge_url(); + const is_sidebar = this.virtual_concierge_display() === 'sidebar'; + const side = this.virtual_concierge_side(); + const position = + side === 'right' + ? { right: '0', top: '0' } + : { left: '0', top: '0' }; + if (is_sidebar) { + this._dialog.open(SidebarEmbedComponent, { + data: { url, side }, + height: '100vh', + width: '28rem', + maxWidth: '100vw', + position, + panelClass: [ + 'sidebar-embed-dialog', + `sidebar-embed-dialog-${side}`, + ], + }); + return; + } + this._dialog.open(FullscreenEmbedComponent, { + data: url, + }); + } +} diff --git a/apps/workplace/src/app/control/control.component.ts b/apps/workplace/src/app/control/control.component.ts index 1cab5f3f44..940b2e74df 100644 --- a/apps/workplace/src/app/control/control.component.ts +++ b/apps/workplace/src/app/control/control.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { SettingsService } from '@placeos/common'; import { FooterMenuComponent } from '../components/footer-menu.component'; import { TopbarComponent } from '../components/topbar.component'; +import { VirtualConciergeButtonComponent } from '../components/virtual-concierge-button.component'; import { ControlSpaceListComponent } from './space-list.component'; @Component({ @@ -14,6 +15,7 @@ import { ControlSpaceListComponent } from './space-list.component'; > +
`, @@ -28,7 +30,12 @@ import { ControlSpaceListComponent } from './space-list.component'; } `, ], - imports: [TopbarComponent, FooterMenuComponent, ControlSpaceListComponent], + imports: [ + TopbarComponent, + FooterMenuComponent, + VirtualConciergeButtonComponent, + ControlSpaceListComponent, + ], }) export class ControlComponent implements OnInit { private _settings = inject(SettingsService); diff --git a/apps/workplace/src/app/deals/deals.component.ts b/apps/workplace/src/app/deals/deals.component.ts index c7b4461590..6fcdafd760 100644 --- a/apps/workplace/src/app/deals/deals.component.ts +++ b/apps/workplace/src/app/deals/deals.component.ts @@ -16,6 +16,7 @@ import { } from '@placeos/components'; import { FooterMenuComponent } from '../components/footer-menu.component'; import { TopbarComponent } from '../components/topbar.component'; +import { VirtualConciergeButtonComponent } from '../components/virtual-concierge-button.component'; import { DealsService } from './deals.service'; @Component({ @@ -184,6 +185,7 @@ import { DealsService } from './deals.service'; }
+ `, @@ -196,6 +198,7 @@ import { DealsService } from './deals.service'; MatMenuModule, TopbarComponent, FooterMenuComponent, + VirtualConciergeButtonComponent, AuthenticatedImageDirective, ], }) diff --git a/apps/workplace/src/app/directory/user-list.component.ts b/apps/workplace/src/app/directory/user-list.component.ts index 83420c5bb7..b75fe4ebae 100644 --- a/apps/workplace/src/app/directory/user-list.component.ts +++ b/apps/workplace/src/app/directory/user-list.component.ts @@ -15,6 +15,7 @@ import { import { searchStaff } from '@placeos/users'; import { FooterMenuComponent } from '../components/footer-menu.component'; import { TopbarComponent } from '../components/topbar.component'; +import { VirtualConciergeButtonComponent } from '../components/virtual-concierge-button.component'; const LETTERS = `ABCDEFGHIJKLMNOPQRSTUVWXYZ`.split(''); @@ -138,6 +139,7 @@ const LETTERS = `ABCDEFGHIJKLMNOPQRSTUVWXYZ`.split(''); } + `, @@ -162,6 +164,7 @@ const LETTERS = `ABCDEFGHIJKLMNOPQRSTUVWXYZ`.split(''); IconComponent, FooterMenuComponent, TopbarComponent, + VirtualConciergeButtonComponent, SafePipe, UserAvatarComponent, MatFormFieldModule, diff --git a/apps/workplace/src/app/events/group-events.component.ts b/apps/workplace/src/app/events/group-events.component.ts index 70dc7472d6..b827576d0d 100644 --- a/apps/workplace/src/app/events/group-events.component.ts +++ b/apps/workplace/src/app/events/group-events.component.ts @@ -5,6 +5,7 @@ import { TranslatePipe } from '@placeos/components'; import { GroupEventCardComponent } from '@placeos/events'; import { FooterMenuComponent } from '../components/footer-menu.component'; import { TopbarComponent } from '../components/topbar.component'; +import { VirtualConciergeButtonComponent } from '../components/virtual-concierge-button.component'; import { GroupEventsFiltersListComponent } from './group-events-filters-list.component'; import { GroupEventsSidebarComponent } from './group-events-sidebar.component'; import { GroupEventsStateService } from './group-events-state.service'; @@ -47,6 +48,7 @@ import { GroupEventsStateService } from './group-events-state.service'; } + `, @@ -76,6 +78,7 @@ import { GroupEventsStateService } from './group-events-state.service'; CommonModule, TopbarComponent, FooterMenuComponent, + VirtualConciergeButtonComponent, GroupEventCardComponent, GroupEventsFiltersListComponent, GroupEventsSidebarComponent, diff --git a/apps/workplace/src/app/explore/event-pin.component.ts b/apps/workplace/src/app/explore/event-pin.component.ts new file mode 100644 index 0000000000..a67dd4dfe1 --- /dev/null +++ b/apps/workplace/src/app/explore/event-pin.component.ts @@ -0,0 +1,91 @@ +import { Component, inject } from '@angular/core'; +import { MAP_FEATURE_DATA } from 'libs/common/src/lib/types'; +import { IconComponent } from 'libs/components/src/lib/icon.component'; + +@Component({ + selector: `event-map-pin`, + template: ` +
+ + + + + + + + + + + +
+ + calendar_month + +
+
+ `, + styles: [ + ` + :host { + display: block; + } + `, + ], + imports: [IconComponent], +}) +export class EventPinComponent { + private _data = inject(MAP_FEATURE_DATA); + + public get fill() { + return this._data.fill || 'var(--secondary)'; + } + + public get stroke() { + return this._data.stroke || 'var(--secondary-focus)'; + } + + public get icon_color() { + return this._data.icon_color || 'var(--secondary-content)'; + } +} diff --git a/apps/workplace/src/app/explore/explore.component.ts b/apps/workplace/src/app/explore/explore.component.ts index 5171abb273..2cae689887 100644 --- a/apps/workplace/src/app/explore/explore.component.ts +++ b/apps/workplace/src/app/explore/explore.component.ts @@ -1,8 +1,15 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { DesksService } from '@placeos/bookings'; -import { ExploreMapViewComponent } from '@placeos/explore'; +import { AsyncHandler, OrganisationService } from '@placeos/common'; +import { IconComponent, LevelPipe, TranslatePipe } from '@placeos/components'; +import { ExploreMapViewComponent, ExploreStateService } from '@placeos/explore'; +import { differenceInMinutes } from 'date-fns'; import { FooterMenuComponent } from '../components/footer-menu.component'; import { TopbarComponent } from '../components/topbar.component'; +import { VirtualConciergeButtonComponent } from '../components/virtual-concierge-button.component'; +import { LandingStateService } from '../landing/landing-state.service'; +import { EventPinComponent } from './event-pin.component'; @Component({ selector: 'app-explore', @@ -15,7 +22,50 @@ import { TopbarComponent } from '../components/topbar.component'; class="relative z-0 flex h-1/2 flex-1 flex-col overflow-hidden sm:h-auto" > + @if (next_event()) { +
+
+
{{ next_event().title }}
+
+ @let lvl = + next_event().system?.zones || + next_event().zones | level; + {{ lvl?.display_name || lvl?.name }} +
+
+
+ @if (begins_in()) { + {{ + 'COMMON.BEGINS_IN' + | translate + : { minutes: begins_in() } + : begins_in() + }} + } @else { + {{ + 'COMMON.ENDS_IN' + | translate + : { minutes: ends_in() } + : ends_in() + }} + } +
+ +
+ } + @if (!hide_nav) { @@ -36,10 +86,44 @@ import { TopbarComponent } from '../components/topbar.component'; } `, ], - imports: [TopbarComponent, FooterMenuComponent, ExploreMapViewComponent], + imports: [ + TopbarComponent, + FooterMenuComponent, + VirtualConciergeButtonComponent, + ExploreMapViewComponent, + IconComponent, + TranslatePipe, + LevelPipe, + MatTooltipModule, + ], }) -export class ExploreComponent implements OnInit { +export class ExploreComponent extends AsyncHandler implements OnInit { private _desks = inject(DesksService); + private _landing = inject(LandingStateService); + private _explore = inject(ExploreStateService); + private _org = inject(OrganisationService); + + private _upcoming = this._landing.upcoming_events; + private _time = signal(Date.now()); + + public readonly next_event = computed(() => { + const upcoming = this._upcoming()?.[0]; + if (!upcoming) return null; + if (upcoming.type === 'desk' || upcoming.system) { + return upcoming; + } + return null; + }); + public readonly begins_in = computed(() => { + const event = this.next_event(); + if (!event) return 0; + return Math.max(0, differenceInMinutes(event.date, this._time())); + }); + public readonly ends_in = computed(() => { + const event = this.next_event(); + if (!event) return 0; + return Math.max(0, differenceInMinutes(event.date_end, this._time())); + }); public get hide_nav() { return localStorage.getItem('PlaceOS.hide_nav') === 'true'; @@ -47,5 +131,24 @@ export class ExploreComponent implements OnInit { public ngOnInit() { this._desks.error_on_host = false; + this.interval('time', () => this._time.set(Date.now())); + } + + public pinEvent() { + const event = this.next_event(); + if (!event) return; + const lvl = this._org.levelWithID(event.system?.zones || event.zones); + const id = event.system?.map_id || event.extension_data.map_id; + this._explore.setLevel(lvl.id); + this.subscription('upcoming', () => + this._explore.setFeatures('_upcoming_', []), + ); + this._explore.setFeatures('_upcoming_', [ + { + location: id, + content: EventPinComponent, + data: { event }, + }, + ]); } } diff --git a/apps/workplace/src/app/landing-new/add-colleagues-modal.component.ts b/apps/workplace/src/app/landing-new/add-colleagues-modal.component.ts new file mode 100644 index 0000000000..983bd85ec1 --- /dev/null +++ b/apps/workplace/src/app/landing-new/add-colleagues-modal.component.ts @@ -0,0 +1,332 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { + AsyncHandler, + i18n, + notifyError, + notifySuccess, + User, +} from '@placeos/common'; +import { + IconComponent, + TranslatePipe, + UserAvatarComponent, +} from '@placeos/components'; +import { LandingStateService } from '../landing/landing-state.service'; + +@Component({ + selector: 'add-colleagues-modal', + standalone: true, + template: ` +
+ +
+

+ {{ 'APP.WORKPLACE.COLLEAGUES_ADD' | translate }} +

+ +
+ + +
+

+ {{ 'APP.WORKPLACE.COLLEAGUES_ADD_DESCRIPTION' | translate }} +

+ + + + search + + @if (loading_users()) { + + } + + + + @if (search_term && search_term.length > 2) { + @let user_list = available_users(); + @if (loading_users()) { +
+ +
+ } @else if (user_list.length > 0) { +
+
+ {{ + 'APP.WORKPLACE.COLLEAGUES_AVAILABLE' + | translate + }} +
+
+ @for (user of user_list; track user.email) { + + } +
+
+ } @else { +
+ person_cancel +
{{ 'FORM.USER_EMPTY' | translate }}
+
+ } + } @else { +
+ search +

+ {{ + (search_term + ? 'APP.WORKPLACE.COLLEAGUE_SEARCH_NO_MATCH' + : 'APP.WORKPLACE.COLLEAGUE_SEARCH_EMPTY' + ) | translate + }} +

+
+ } + + + @if (selected_users().length > 0) { +
+
+ {{ + 'APP.WORKPLACE.COLLEAGUES_SELECTED' + | translate + : { + count: selected_users().length, + } + : selected_users().length + }} +
+ @for (user of selected_users(); track user.email) { +
+ +
+
{{ user.name }}
+
+ {{ user.email }} +
+
+ +
+ } +
+ } + + @if (loading()) { +
+ + {{ + 'APP.WORKPLACE.COLLEAGUES_ADDING' | translate + }} +
+ } +
+ + +
+ + +
+
+ `, + styles: [``], + imports: [ + CommonModule, + FormsModule, + TranslatePipe, + IconComponent, + MatRippleModule, + MatProgressSpinnerModule, + MatFormFieldModule, + MatInputModule, + UserAvatarComponent, + ], +}) +export class AddColleaguesModalComponent + extends AsyncHandler + implements OnInit +{ + private _state = inject(LandingStateService); + private _dialog_ref = inject(MatDialogRef, { + optional: true, + }); + + public readonly loading = signal(false); + public readonly selected_users = signal([]); + public readonly search_term_signal = signal(''); + + private readonly _search_results = this._state.search_results; + private readonly _loading_state = this._state.loading; + private readonly _existing_contacts = this._state.contacts; + + public readonly loading_users = computed(() => !!this._loading_state()); + public readonly available_users = computed(() => this._search_results()); + public readonly existing_contacts = computed(() => + this._existing_contacts(), + ); + + public get search_term() { + return this.search_term_signal(); + } + + public set search_term(value: string) { + this.search_term_signal.set(value); + this._state.setOptions({ search: value }); + } + + ngOnInit() { + // Initialize with empty search + this._state.setOptions({ search: '' }); + } + + public toggleUser(user: User) { + const current_users = this.selected_users(); + const is_selected = current_users.find((u) => u.email === user.email); + + if (is_selected) { + // Remove from selection + this.selected_users.set( + current_users.filter((u) => u.email !== user.email), + ); + } else { + // Add to selection + this.selected_users.set([...current_users, user]); + } + } + + public isUserSelected(user: User): boolean { + return !!this.selected_users().find((u) => u.email === user.email); + } + + public isExistingContact(user: User): boolean { + return !!this.existing_contacts().find((u) => u.email === user.email); + } + + public async confirm() { + this.loading.set(true); + try { + // Add all selected users as contacts + await this._state.addContacts(this.selected_users()); + const count = this.selected_users().length; + notifySuccess( + i18n('APP.WORKPLACE.COLLEAGUES_ADD_SUCCESS', { count }, count), + ); + + this._dialog_ref?.close(true); + } catch (error) { + console.error('Error adding colleagues:', error); + notifyError(i18n('APP.WORKPLACE.COLLEAGUES_ADD_ERROR')); + } finally { + this.loading.set(false); + } + } + + public dismiss() { + this._dialog_ref?.close(false); + } +} diff --git a/apps/workplace/src/app/landing-new/chat-view.component.ts b/apps/workplace/src/app/landing-new/chat-view.component.ts new file mode 100644 index 0000000000..6ed1214f58 --- /dev/null +++ b/apps/workplace/src/app/landing-new/chat-view.component.ts @@ -0,0 +1,126 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, signal } from '@angular/core'; +import { + MatBottomSheetModule, + MatBottomSheetRef, +} from '@angular/material/bottom-sheet'; +import { MatRippleModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { RouterModule } from '@angular/router'; +import { userSignal } from '@placeos/common'; +import { IconComponent } from '@placeos/components'; + +@Component({ + selector: 'app-chat-view', + template: ` +
+ +
+

+ Welcome {{ user().name }}, it's + {{ time() | date: 'fullDate' }} +

+
+
+ +
+

I'm Ben, your virtual concierge

+

Try asking me:

+
+ + + +
+ + + +

Quick Actions (In App)

+ +
+
+ `, + styles: [``], + imports: [ + CommonModule, + MatBottomSheetModule, + IconComponent, + MatRippleModule, + MatFormFieldModule, + MatInputModule, + RouterModule, + ], +}) +export class ChatViewComponent { + private _bottom_ref = inject(MatBottomSheetRef); + + public readonly user = userSignal(); + public readonly time = signal(Date.now()); + + public readonly close = () => this._bottom_ref.dismiss(); +} diff --git a/apps/workplace/src/app/landing-new/landing-available-now.component.ts b/apps/workplace/src/app/landing-new/landing-available-now.component.ts new file mode 100644 index 0000000000..7d91a15e59 --- /dev/null +++ b/apps/workplace/src/app/landing-new/landing-available-now.component.ts @@ -0,0 +1,320 @@ +import { + Component, + computed, + inject, + resource, + signal, +} from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { RouterLink } from '@angular/router'; +import { + BookingAsset, + bookedResourceList, + BookingFormService, + loadLockerResources, +} from '@placeos/bookings'; +import { + BookingRuleset, + currentUser, + OrganisationService, + rulesForResource, + settingSignal, +} from '@placeos/common'; +import { + BuildingPipe, + IconComponent, + TranslatePipe, +} from '@placeos/components'; +import { showMetadata } from '@placeos/ts-client'; +import { addMinutes, getUnixTime } from 'date-fns'; +import { LandingStateService } from '../landing/landing-state.service'; +import { + AvailableTab, + availableNowTabs, + landingFeatures, +} from './landing-features'; + +/** Length in minutes of the time from now that a resource must be free */ +const AVAILABLE_WINDOW = 60; + +/** Display details of each tab. `type` is set for tabs with a booking query */ +const TAB_DETAILS: Record< + AvailableTab, + { + name: string; + icon: string; + empty: string; + loading: string; + type?: 'desk' | 'locker'; + } +> = { + desks: { + name: 'RESOURCE.DESKS', + icon: 'desk', + empty: 'APP.WORKPLACE.AVAILABLE_NOW_DESKS_EMPTY', + loading: 'BOOKINGS.DESKS_LOADING', + type: 'desk', + }, + rooms: { + name: 'RESOURCE.ROOMS', + icon: 'meeting_room', + empty: 'APP.WORKPLACE.AVAILABLE_LIST_ROOMS_EMPTY', + loading: '', + }, + lockers: { + name: 'RESOURCE.LOCKERS', + icon: 'lock', + empty: 'APP.WORKPLACE.AVAILABLE_NOW_LOCKERS_EMPTY', + loading: 'BOOKINGS.LOCKERS_LOADING', + type: 'locker', + }, +}; + +@Component({ + selector: 'landing-available-now', + template: ` +
+
+

+ {{ 'APP.WORKPLACE.AVAILABLE_LIST_HEADER' | translate }} +

+
+ @if (tabs().length > 0) { +
+ @for (tab of tabs(); track tab) { + + } +
+ } + @if (active_tab(); as tab) { +
+ @if (loading()) { +
+ +
+ {{ tab_details[tab].loading | translate }} +
+
+ } @else { + @for (lvl of filtered_levels(); track lvl.id) { + + {{ + tab_details[tab].icon + }} +
+ @let bld = lvl.parent_id | building; +
+ {{ lvl.display_name || lvl.name }} +
+ @if (bld) { +
+ {{ bld.display_name || bld.name }} +
+ } +
+
+ {{ + 'APP.WORKPLACE.AVAILABLE_NOW_FREE' + | translate + : { + count: free_by_level()[ + lvl.id + ], + } + }} +
+ @if (has_explore()) { + chevron_right + } +
+ } @empty { +
+ {{ + tab_details[tab].icon + }} +
+ {{ tab_details[tab].empty | translate }} +
+
+ } + } +
+ } + @if (has_explore()) { + {{ 'APP.WORKPLACE.AVAILABLE_NOW_VIEW_MAP' | translate }} + } +
+ `, + imports: [ + MatRippleModule, + IconComponent, + RouterLink, + BuildingPipe, + TranslatePipe, + MatProgressSpinnerModule, + ], +}) +export class LandingAvailableNowComponent { + private _state = inject(LandingStateService); + private _booking_form = inject(BookingFormService); + private _org = inject(OrganisationService); + + public readonly tab_details = TAB_DETAILS; + public readonly features = landingFeatures(); + public readonly hide_spaces = settingSignal('hide_landing_spaces'); + public readonly hide_rooms = settingSignal('hide_landing_rooms'); + public readonly has_explore = computed(() => + this.features().includes('explore'), + ); + + /** Tabs that the features and the landing settings allow */ + public readonly tabs = computed(() => + availableNowTabs( + this.features(), + !!this.hide_spaces(), + !!this.hide_rooms(), + ), + ); + /** Tab that the user selected. Null until the user selects one */ + public readonly selected_tab = signal(null); + /** Selected tab, or the first tab that is enabled */ + public readonly active_tab = computed(() => { + const tabs = this.tabs(); + const selected = this.selected_tab(); + return tabs.includes(selected) ? selected : (tabs[0] ?? null); + }); + + public readonly levels = this._state.level_occupancy; + /** Rooms with a live status of free. No request is made for these */ + public readonly free_spaces = this._state.free_space_list; + + /** + * Desks or lockers that are free now for the active tab. This query does + * not use the booking form state, so it loads on a first visit and does + * not show the time of a booking that the user started earlier. It loads + * again only when the tab or the building changes. + */ + private readonly _available = resource({ + params: () => { + const type = this.tab_details[this.active_tab()]?.type; + const building_id = this._org.active_building()?.id; + return type && building_id ? { type, building_id } : undefined; + }, + loader: ({ params: { type, building_id } }) => + this._loadAvailable(type, building_id).catch( + () => [] as BookingAsset[], + ), + }); + public readonly available_resources = computed( + () => this._available.value() ?? ([] as BookingAsset[]), + ); + public readonly loading = computed(() => this._available.isLoading()); + + /** Number of free resources of the active tab on each level */ + public readonly free_by_level = computed(() => { + const zone_lists = + this.active_tab() === 'rooms' + ? this.free_spaces().map((space) => space.zones) + : this.available_resources().map((item) => [item.zone?.id]); + const mapping: Record = {}; + for (const zones of zone_lists) { + const lvl = this._org.levelWithID(zones); + if (lvl) mapping[lvl.id] = (mapping[lvl.id] || 0) + 1; + } + return mapping; + }); + + public readonly filtered_levels = computed(() => { + const counts = this.free_by_level(); + return this.levels().filter((lvl) => counts[lvl.id] > 0); + }); + + /** + * List the desks or lockers of the building that are free for + * `AVAILABLE_WINDOW` minutes from now. + * Requests: the resource list, then the booking rules and the booked + * resources of the building. The count does not grow with the number of + * desks or levels. + */ + private async _loadAvailable( + type: 'desk' | 'locker', + building_id: string, + ): Promise { + const date = Date.now(); + const resources = + type === 'desk' + ? await this._booking_form.loadResourceList('desks') + : ((await loadLockerResources( + this._org, + building_id, + )) as BookingAsset[]); + if (!resources.length) return []; + const [rules, booked_ids] = await Promise.all([ + showMetadata(building_id, `${type}_booking_rules`) + .then((metadata): BookingRuleset[] => + metadata.details instanceof Array ? metadata.details : [], + ) + .catch((): BookingRuleset[] => []), + bookedResourceList( + { + period_start: getUnixTime(date), + period_end: getUnixTime(addMinutes(date, AVAILABLE_WINDOW)), + type, + zones: building_id, + }, + resources.length, + ), + ]); + const host = currentUser(); + const groups = host?.groups || []; + return resources.filter( + (asset) => + asset.bookable !== false && + !booked_ids.includes(asset.id) && + (!asset.groups?.length || + asset.groups.some((group) => groups.includes(group))) && + !rulesForResource( + { date, duration: AVAILABLE_WINDOW, resource: asset, host }, + rules, + ).hidden, + ); + } +} diff --git a/apps/workplace/src/app/landing-new/landing-colleagues-new.component.ts b/apps/workplace/src/app/landing-new/landing-colleagues-new.component.ts new file mode 100644 index 0000000000..ba79b15dca --- /dev/null +++ b/apps/workplace/src/app/landing-new/landing-colleagues-new.component.ts @@ -0,0 +1,370 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, signal } from '@angular/core'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatMenuModule } from '@angular/material/menu'; +import { Router } from '@angular/router'; +import { BookingFormService } from '@placeos/bookings'; +import { + Booking, + CalendarEvent, + i18n, + notifySuccess, + settingSignal, + User, +} from '@placeos/common'; +import { + IconComponent, + openConfirmModal, + TranslatePipe, + UserAvatarComponent, +} from '@placeos/components'; +import { EventFormService } from '@placeos/events'; +import { LandingStateService } from '../landing/landing-state.service'; +import { TeamScheduleService } from '../team-schedule/team-schedule.service'; +import { AddColleaguesModalComponent } from './add-colleagues-modal.component'; +import { landingFeatures } from './landing-features'; + +@Component({ + selector: 'landing-colleagues-new', + template: ` +
+ @let contact_list = contacts(); +
+

+ {{ 'APP.WORKPLACE.COLLEAGUES' | translate }} +

+
+ @if (selected_users().length > 0) { + {{ + 'COMMON.SELECTED_COUNT' + | translate: { count: selected_users().length } + }} + • + } + {{ + 'APP.WORKPLACE.COLLEAGUES_COUNT' + | translate + : { + count: contact_list?.length, + } + : contact_list?.length + }} +
+
+ @if (selected_users().length > 0) { +
+ @if (can_book_group_desk()) { + + } + @if (features().includes('spaces')) { + + } +
+ } + @if (contact_list?.length) { +
+ @for (user of contact_list; track user) { +
+ + +
+
{{ user.name }}
+ {{ user.email }} +
+ + + @if (features().includes('spaces')) { + + } + @if (has_team_schedule()) { + + + } + + + @if (isSelected(user)) { +
+ } +
+ } +
+ } @else { +
+ +

+ {{ 'APP.WORKPLACE.COLLEAGUES_EMPTY' | translate }} +

+
+ } + +
+ `, + imports: [ + CommonModule, + MatRippleModule, + MatMenuModule, + MatCheckboxModule, + IconComponent, + TranslatePipe, + UserAvatarComponent, + ], +}) +export class LandingColleaguesNewComponent { + private _state = inject(LandingStateService); + private _dialog = inject(MatDialog); + private _event_form = inject(EventFormService); + private _booking_form = inject(BookingFormService); + private _router = inject(Router); + private _team_schedule = inject(TeamScheduleService); + + public readonly contacts = this._state.contacts; + public readonly features = landingFeatures(); + public readonly has_team_schedule = computed(() => + this.features().includes('team-schedule'), + ); + public readonly selected_users = signal([]); + private readonly _allow_desk_groups = settingSignal( + 'desks.allow_groups', + false, + ); + /** A desk booking for the selected colleagues is a group booking */ + public readonly can_book_group_desk = computed( + () => this.features().includes('desks') && this._allow_desk_groups(), + ); + + public openAddColleaguesModal() { + // The modal saves through the landing state, which updates `contacts` + const dialog_ref = this._dialog.open(AddColleaguesModalComponent, { + maxWidth: '100vw', + maxHeight: '100vh', + panelClass: 'panel', + data: {}, + }); + // The team schedule loads the colleagues one time + dialog_ref.afterClosed().subscribe((added) => { + if (added) this._team_schedule.refresh(); + }); + } + + public isSelected(user: User): boolean { + return this.selected_users().some((u) => u.email === user.email); + } + + public toggleSelection(user: User) { + const current = this.selected_users(); + if (this.isSelected(user)) { + this.selected_users.set( + current.filter((u) => u.email !== user.email), + ); + } else { + this.selected_users.set([...current, user]); + } + } + + public clearSelection() { + this.selected_users.set([]); + } + + public async bookMeeting(user: User) { + this._router.navigate(['/book', 'meeting', 'form']); + const event = new CalendarEvent({ + attendees: [user], + }); + setTimeout(() => this._event_form.newForm(event), 300); + } + + public async bookMeetingWithSelected() { + const attendees = this.selected_users(); + if (attendees.length === 0) return; + this._router.navigate(['/book', 'meeting', 'form']); + const event = new CalendarEvent({ + attendees: attendees, + }); + setTimeout(() => { + this._event_form.newForm(event); + this.clearSelection(); + }, 300); + } + + public async bookDeskWithSelected() { + const members = this.selected_users(); + if (members.length === 0) return; + this._router.navigate(['/book', 'desk', 'form']); + setTimeout(() => { + this._booking_form.newForm( + 'desk', + new Booking({ booking_type: 'desk' }), + ); + this._booking_form.setOptions({ group: true, members }); + this.clearSelection(); + }, 300); + } + + public async removeColleague(user: User) { + const resp = await openConfirmModal( + { + title: i18n('APP.WORKPLACE.COLLEAGUES_REMOVE_TITLE'), + content: i18n('APP.WORKPLACE.COLLEAGUES_REMOVE_MSG', { + name: user.name, + }), + icon: { content: 'person_remove' }, + }, + this._dialog, + ); + + if (resp.reason !== 'done') return; + resp.loading(i18n('APP.WORKPLACE.COLLEAGUES_REMOVE_LOADING')); + await this._state.removeContact(user); + this._team_schedule.refresh(); + // Remove from selection if selected + if (this.isSelected(user)) { + this.selected_users.set( + this.selected_users().filter((u) => u.email !== user.email), + ); + } + notifySuccess(i18n('APP.WORKPLACE.COLLEAGUES_REMOVE_SUCCESS')); + this._dialog.closeAll(); + } + + public isFavorite(user: User): boolean { + return this._team_schedule.isFavorite(user); + } + + public isTeamMember(user: User): boolean { + return this._team_schedule.isTeamMember(user); + } + + public toggleFavorite(user: User) { + this._team_schedule.toggleFavoriteByUser(user); + } + + public toggleTeamMember(user: User) { + this._team_schedule.toggleTeamMemberByUser(user); + } +} diff --git a/apps/workplace/src/app/landing-new/landing-desk-week.component.ts b/apps/workplace/src/app/landing-new/landing-desk-week.component.ts new file mode 100644 index 0000000000..432342b63b --- /dev/null +++ b/apps/workplace/src/app/landing-new/landing-desk-week.component.ts @@ -0,0 +1,649 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + inject, + OnDestroy, + OnInit, + resource, + signal, +} from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Router } from '@angular/router'; +import { + BookingDetailsModalComponent, + BookingFormService, + queryBookings, +} from '@placeos/bookings'; +import { + AsyncHandler, + Booking, + i18n, + OrganisationService, + settingSignal, + SettingsService, +} from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { + addDays, + addWeeks, + endOfDay, + endOfWeek, + format, + getUnixTime, + isBefore, + isSameDay, + startOfDay, + startOfWeek, +} from 'date-fns'; +import { ScheduleStateService } from '../schedule/schedule-state.service'; + +interface WeekDay { + id: string; + date: number; + day_name: string; + day_number: number; + is_past: boolean; + is_bookable: boolean; + is_today: boolean; + is_weekend: boolean; +} + +type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +@Component({ + selector: 'landing-desk-week', + template: ` +
+
+
+

+ {{ 'APP.WORKPLACE.DESK_WEEK_TITLE' | translate }} +

+

{{ week_range() }}

+
+
+ + +
+ + +
+
+
+ +
+ @for (day of weekdays(); track day.id) { +
+
+
+ {{ + day.is_weekend ? 'star' : 'event_note' + }} + {{ + day.day_name + }} +
+ + {{ day.day_number }} + +
+
+ @if (bookings_by_date()[day.id]?.length; as count) { + @for ( + booking of bookings_by_date()[day.id]; + track booking.id + ) { + + } + } @else if (day.is_bookable) { + + } @else { +
+ desk +
+ } +
+
+ } +
+ + +
+ `, + styles: [``], + imports: [ + CommonModule, + MatRippleModule, + MatTooltipModule, + IconComponent, + TranslatePipe, + ], +}) +export class LandingDeskWeekComponent + extends AsyncHandler + implements OnInit, OnDestroy +{ + private _dialog = inject(MatDialog); + private _org = inject(OrganisationService); + private _settings = inject(SettingsService); + private _router = inject(Router); + private _booking_form = inject(BookingFormService); + private _schedule = inject(ScheduleStateService); + + public readonly selected_date = signal(Date.now()); + public readonly offset_weekday = settingSignal( + 'week_start', + 0, + ); + public readonly available_days = settingSignal( + 'desks.available_period', + 90, + ); + public readonly end_date = computed(() => + endOfDay(addDays(Date.now(), this.available_days())).valueOf(), + ); + + public readonly weekdays = computed(() => { + const days: WeekDay[] = []; + const week_start = startOfWeek(this.selected_date(), { + weekStartsOn: this.offset_weekday(), + }); + const today = Date.now(); + const today_start = startOfDay(today); + const end_date = this.end_date(); + for (let i = 0; i < 7; i++) { + const date = addDays(week_start, i); + const date_value = date.valueOf(); + const day_of_week = date.getDay(); + const is_past = isBefore(date, today_start); + days.push({ + id: format(date, 'yyyy-MM-dd'), + date: date_value, + day_name: format(date, 'EEE'), + day_number: date.getDate(), + is_past, + is_bookable: !is_past && date_value <= end_date, + is_today: isSameDay(date, today), + is_weekend: day_of_week === 0 || day_of_week === 6, + }); + } + return days; + }); + + public readonly weekend_days = computed(() => + this.weekdays().filter((day) => day.is_weekend), + ); + + public readonly week_range = computed(() => { + const week_start = startOfWeek(this.selected_date(), { + weekStartsOn: this.offset_weekday(), + }); + const week_end = endOfWeek(this.selected_date(), { + weekStartsOn: this.offset_weekday(), + }); + return `${format(week_start, 'dd MMM')} - ${format(week_end, 'dd MMM yyyy')}`; + }); + + public readonly is_current_week = computed(() => { + const today = Date.now(); + const current_week_start = startOfWeek(today, { + weekStartsOn: this.offset_weekday(), + }); + const selected_week_start = startOfWeek(this.selected_date(), { + weekStartsOn: this.offset_weekday(), + }); + return isSameDay(current_week_start, selected_week_start); + }); + public readonly can_go_previous_week = computed(() => + this._weekHasBookableDay(addWeeks(this.selected_date(), -1).valueOf()), + ); + public readonly can_go_next_week = computed(() => + this._weekHasBookableDay(addWeeks(this.selected_date(), 1).valueOf()), + ); + public readonly can_go_next_monday = computed(() => + this._weekHasBookableDay(this._nextMonday().valueOf()), + ); + + private readonly _desk_bookings_resource = resource({ + params: () => ({ + date: this.selected_date(), + week_start: this.offset_weekday(), + }), + loader: ({ params: { date, week_start } }) => { + const start = startOfWeek(date, { weekStartsOn: week_start }); + const end = endOfWeek(date, { weekStartsOn: week_start }); + return queryBookings({ + period_start: getUnixTime(start), + period_end: getUnixTime(end), + type: 'desk', + include_checked_out: true, + }).catch(() => [] as Booking[]); + }, + }); + + public readonly desk_bookings = computed( + () => this._desk_bookings_resource.value() ?? ([] as Booking[]), + ); + public readonly loading = computed(() => + this._desk_bookings_resource.isLoading(), + ); + + public readonly bookings_by_date = computed(() => { + const bookings = this.desk_bookings(); + const date_map: Record = {}; + for (const booking of bookings || []) { + const date_key = format(booking.date, 'yyyy-MM-dd'); + if (!date_map[date_key]) date_map[date_key] = []; + date_map[date_key].push(booking); + } + return date_map; + }); + + ngOnInit(): void { + // Start polling for updates + this.interval( + 'poll_bookings', + () => this._desk_bookings_resource.reload(), + 2 * 60 * 1000, + ); + } + + public goToToday(): void { + this.selected_date.set(Date.now()); + } + + public goToNextMonday(): void { + if (!this.can_go_next_monday()) return; + this.selected_date.set(this._nextMonday().valueOf()); + } + + public previousWeek(): void { + if (!this.can_go_previous_week()) return; + this.selected_date.set(addWeeks(this.selected_date(), -1).valueOf()); + } + + public nextWeek(): void { + if (!this.can_go_next_week()) return; + this.selected_date.set(addWeeks(this.selected_date(), 1).valueOf()); + } + + public buildingName(booking: Booking): string { + const level = this._org.levelWithID(booking.zones); + const building = level + ? this._org.buildings.find((b) => b.id === level.parent_id) + : null; + return ( + building?.display_name || + building?.name || + i18n('COMMON.WORK_OFFICE') + ); + } + + public deskName(booking: Booking): string { + return booking.asset_name || booking.description || i18n('COMMON.DESK'); + } + + public bookingTooltip(booking: Booking): string { + const location = this.buildingName(booking); + const desk = this.deskName(booking); + const time = booking.all_day + ? i18n('COMMON.ALL_DAY') + : `${format(booking.date, 'h:mm a')} - ${format(booking.date_end, 'h:mm a')}`; + return `${desk}\n${location}\n${time}`; + } + + public viewBooking(booking: Booking): void { + this._dialog.open(BookingDetailsModalComponent, { + data: { + booking, + edit_fn: (b: Booking) => this._schedule.editBooking(b), + // Reload only. The week in view must stay, also when the + // user cancels the delete prompt. + remove_fn: async (b: Booking, s?: boolean) => { + await this._schedule.remove(b, s); + this._desk_bookings_resource.reload(); + }, + end_fn: (b: Booking) => this._schedule.end(b), + }, + }); + } + + public bookDesk(date: number): void { + if (date > this.end_date()) return; + // Navigate first, then prepare the form. The desk flow page resets any + // unsaved form in its ngOnInit, so populating beforehand would be wiped. + // A native setTimeout is used (not AsyncHandler.timeout) because this + // component is destroyed during navigation, which would cancel it. + this._router.navigate(['/book', 'desk', 'form']); + setTimeout( + () => + this._booking_form.newForm( + 'desk', + new Booking({ date, booking_type: 'desk' }), + ), + 300, + ); + } + + private _weekHasBookableDay(date: number): boolean { + const week_start = startOfWeek(date, { + weekStartsOn: this.offset_weekday(), + }).valueOf(); + const week_end = endOfWeek(date, { + weekStartsOn: this.offset_weekday(), + }).valueOf(); + return ( + week_end >= startOfDay(Date.now()).valueOf() && + week_start <= this.end_date() + ); + } + + private _nextMonday(): Date { + const today = new Date(); + const day_of_week = today.getDay(); + const days_until_monday = day_of_week === 0 ? 1 : 8 - day_of_week; + return addDays(today, days_until_monday); + } +} diff --git a/apps/workplace/src/app/landing-new/landing-favourites-new.component.ts b/apps/workplace/src/app/landing-new/landing-favourites-new.component.ts new file mode 100644 index 0000000000..4e6dd3cbfb --- /dev/null +++ b/apps/workplace/src/app/landing-new/landing-favourites-new.component.ts @@ -0,0 +1,409 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, resource } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Router } from '@angular/router'; +import { BookingAsset, BookingFormService } from '@placeos/bookings'; +import { + OrganisationService, + SETTING_KEYS, + settingSignal, + SettingsService, +} from '@placeos/common'; +import { + AuthenticatedImageDirective, + IconComponent, + LevelPipe, + TranslatePipe, +} from '@placeos/components'; +import { EventFormService } from '@placeos/events'; +import { ExploreSpacesService } from '@placeos/explore'; +import { LandingStateService } from '../landing/landing-state.service'; +import { landingFeatures } from './landing-features'; + +export interface FavoriteEntry { + id: string; + name: string; + type: 'room' | 'desk' | 'locker' | 'parking'; + zones: string[]; + images?: string[]; + capacity?: number; +} + +@Component({ + selector: 'landing-favourites-new', + template: ` +
+
+

+ {{ 'COMMON.FAVOURITES' | translate }} +

+
+ {{ + 'APP.WORKPLACE.FAVOURITES_COUNT' + | translate + : { count: favourites().length } + : favourites().length + }} +
+
+ @if (favourites().length) { +
+ @for (item of favourites(); track item.id) { +
+
+
+ @if (item.images?.length) { + + } @else { + + } +
+
+
+ {{ item.name }} +
+
+ place +
+ @let lvl = item.zones | level; + {{ lvl?.display_name || lvl?.name }} +
+
+ @if (item.capacity) { +
+ + people + +
+ {{ + 'APP.WORKPLACE.CAPACITY' + | translate + : { + count: item.capacity, + } + }} +
+
+ } +
+ +
+ +
+ } +
+ } @else { +
+ +

+ {{ 'APP.WORKPLACE.FAVOURITES_EMPTY' | translate }} +

+
+ } +
+ `, + providers: [ExploreSpacesService], + imports: [ + CommonModule, + IconComponent, + TranslatePipe, + LevelPipe, + MatRippleModule, + MatTooltipModule, + AuthenticatedImageDirective, + ], +}) +export class LandingFavouritesNewComponent { + private _explore = inject(ExploreSpacesService); + private _state = inject(LandingStateService); + private _event_form = inject(EventFormService); + private _booking_form = inject(BookingFormService); + private _router = inject(Router); + private _settings = inject(SettingsService); + private _org = inject(OrganisationService); + + // Favorite IDs + public readonly fav_space_ids = settingSignal( + SETTING_KEYS.FAVORITE_ROOMS, + [], + true, + ); + public readonly fav_parking_ids = settingSignal( + SETTING_KEYS.FAVORITE_PARKING_SPACES, + [], + true, + ); + public readonly fav_desk_ids = settingSignal( + SETTING_KEYS.FAVORITE_DESKS, + [], + true, + ); + public readonly fav_locker_ids = settingSignal( + SETTING_KEYS.FAVORITE_LOCKERS, + [], + true, + ); + + public readonly features = landingFeatures(); + + // Full resource lists. A list loads only when its feature is enabled. + /** + * Rooms of the active building that the event form can book. The list + * is from the landing state: the event form loads its space list only + * after the user opens a booking page. + */ + public readonly all_spaces = computed(() => + this.features().includes('spaces') + ? this._state + .space_list() + .filter((s) => s.email && !s.room_booking_url) + : [], + ); + private readonly _desks_resource = resource({ + params: () => this._buildingWithFeature('desks'), + loader: () => this._booking_form.loadResourceList('desks'), + }); + public readonly all_desks = computed( + () => this._desks_resource.value() ?? ([] as BookingAsset[]), + ); + private readonly _parking_resource = resource({ + params: () => this._buildingWithFeature('parking'), + loader: () => this._booking_form.loadResourceList('parking-spaces'), + }); + public readonly all_parking = computed( + () => this._parking_resource.value() ?? ([] as BookingAsset[]), + ); + private readonly _lockers_resource = resource({ + params: () => this._buildingWithFeature('lockers'), + loader: () => this._booking_form.loadResourceList('lockers'), + }); + public readonly all_lockers = computed( + () => this._lockers_resource.value() ?? ([] as BookingAsset[]), + ); + + public readonly favourites = computed(() => { + let list: FavoriteEntry[] = []; + + // Filter spaces by favorite IDs + const fav_space_ids = this.fav_space_ids(); + const spaces = this.all_spaces().filter((s) => + fav_space_ids.includes(s.id), + ); + list = list.concat( + spaces.map((s) => ({ + type: 'room' as const, + id: s.id, + name: s.display_name || s.name, + zones: s.zones || [], + images: s.images, + capacity: s.capacity, + })), + ); + + // Filter parking by favorite IDs + const fav_parking_ids = this.fav_parking_ids(); + const parking = this.all_parking().filter((p) => + fav_parking_ids.includes(p.id), + ); + list = list.concat( + parking.map((p) => ({ + type: 'parking' as const, + id: p.id, + name: p.name, + zones: (p as BookingAsset & { zones?: string[] }).zones || [], + })), + ); + + // Filter desks by favorite IDs + const fav_desk_ids = this.fav_desk_ids(); + const desks = this.all_desks().filter((d) => + fav_desk_ids.includes(d.id), + ); + list = list.concat( + desks.map((d) => ({ + type: 'desk' as const, + id: d.id, + name: d.name, + zones: [d.zone?.id], + })), + ); + + // Filter lockers by favorite IDs + const fav_locker_ids = this.fav_locker_ids(); + const lockers = this.all_lockers().filter((l) => + fav_locker_ids.includes(l.id), + ); + list = list.concat( + lockers.map((l) => ({ + type: 'locker' as const, + id: l.id, + name: l.name, + zones: (l as BookingAsset & { zones?: string[] }).zones, + })), + ); + + return list; + }); + + /** ID of the active building when the feature is enabled */ + private _buildingWithFeature(feature: string) { + return this.features().includes(feature) + ? this._org.active_building()?.id || undefined + : undefined; + } + + public removeFavourite(item: FavoriteEntry) { + let fav_list: string[] = []; + let key = ''; + const updated_list = (list: string[]) => + list.filter((id) => id !== item.id); + + switch (item.type) { + case 'room': + fav_list = this.fav_space_ids(); + key = SETTING_KEYS.FAVORITE_ROOMS; + // Optimistically update UI + this.fav_space_ids.set(updated_list(fav_list)); + break; + case 'desk': + fav_list = this.fav_desk_ids(); + key = SETTING_KEYS.FAVORITE_DESKS; + // Optimistically update UI + this.fav_desk_ids.set(updated_list(fav_list)); + break; + case 'parking': + fav_list = this.fav_parking_ids(); + key = SETTING_KEYS.FAVORITE_PARKING_SPACES; + // Optimistically update UI + this.fav_parking_ids.set(updated_list(fav_list)); + break; + case 'locker': + fav_list = this.fav_locker_ids(); + key = SETTING_KEYS.FAVORITE_LOCKERS; + // Optimistically update UI + this.fav_locker_ids.set(updated_list(fav_list)); + break; + } + + // Save to settings in background + this._settings.saveUserSetting(key, updated_list(fav_list)); + } + + public bookResource(item: FavoriteEntry) { + const use_new_features = this._settings.get('app.new_features'); + + switch (item.type) { + case 'room': + // Find the full space object + const space = this.all_spaces().find((s) => s.id === item.id); + if (!space) return; + + this._event_form.newForm(); + if (use_new_features) { + this._router.navigate(['/book', 'meeting']); + } else { + this._router.navigate(['/book', 'spaces']); + } + setTimeout(() => { + this._event_form.model.update((m) => ({ + ...m, + resources: [space], + })); + }, 300); + break; + + case 'desk': + this._router.navigate( + ['/book', use_new_features ? 'desk' : 'desks'], + { queryParams: { asset_id: item.id } }, + ); + break; + + case 'parking': + const parking = this.all_parking().find( + (p) => p.id === item.id, + ); + if (!parking) return; + + // Initialize form with parking before navigating + this._booking_form.newForm('parking'); + this._booking_form.setOptions({ type: 'parking' }); + this._booking_form.model.update((m) => ({ + ...m, + resources: [parking], + asset_id: parking.id, + booking_type: 'parking', + })); + + // Navigate after setting the resource + this._router.navigate(['/book', 'parking']); + break; + + case 'locker': + const locker = this.all_lockers().find((l) => l.id === item.id); + if (!locker) return; + + // Initialize form with locker before navigating + this._booking_form.newForm('locker'); + this._booking_form.setOptions({ type: 'locker' }); + this._booking_form.model.update((m) => ({ + ...m, + resources: [locker], + asset_id: locker.id, + booking_type: 'locker', + })); + + // Navigate after setting the resource + this._router.navigate(['/book', 'locker']); + break; + } + } +} diff --git a/apps/workplace/src/app/landing-new/landing-features.ts b/apps/workplace/src/app/landing-new/landing-features.ts new file mode 100644 index 0000000000..63eb1e2a7c --- /dev/null +++ b/apps/workplace/src/app/landing-new/landing-features.ts @@ -0,0 +1,85 @@ +import { computed, Signal } from '@angular/core'; +import { settingSignal, userSignal } from '@placeos/common'; + +export type AvailableTab = 'desks' | 'rooms' | 'lockers'; + +/** + * Features that are enabled for the current user. + * Uses the same rules as the top menu: the `app.features` list, limited by + * `app.feature_groups` to the user groups that can use each feature. + */ +export function landingFeatures(): Signal { + const feature_list = settingSignal('features', []); + const feature_groups = settingSignal>( + 'feature_groups', + {}, + ); + const user = userSignal(); + return computed(() => { + const groups = user()?.groups || []; + const limits = feature_groups() || {}; + return (feature_list() || []).filter( + (name) => + !limits[name]?.length || + limits[name].some((_) => groups.includes(_)), + ); + }); +} + +/** + * Tabs of the Available Now card that are enabled, in display order. + * `hide_spaces` removes the desk and locker summaries by level. + * `hide_rooms` removes the room summary. + */ +export function availableNowTabs( + features: string[], + hide_spaces: boolean, + hide_rooms: boolean, +): AvailableTab[] { + const tabs: AvailableTab[] = []; + if (!hide_spaces && features.includes('desks')) tabs.push('desks'); + if (!hide_rooms && features.includes('spaces')) tabs.push('rooms'); + if (!hide_spaces && features.includes('lockers')) tabs.push('lockers'); + return tabs; +} + +/** Booking links of the Quick Actions card with the feature for each link */ +export const QUICK_LINKS = [ + { + feature: 'desks', + route: ['/book', 'desk'], + icon: 'desk', + name: 'APP.WORKPLACE.QUICK_LINK_DESKS', + }, + { + feature: 'spaces', + route: ['/book', 'meeting'], + icon: 'meeting_room', + name: 'APP.WORKPLACE.QUICK_LINK_ROOMS', + }, + { + feature: 'parking', + route: ['/book', 'parking'], + icon: 'directions_car', + name: 'APP.WORKPLACE.QUICK_LINK_PARKING', + }, + { + feature: 'lockers', + route: ['/book', 'locker'], + icon: 'lock', + name: 'APP.WORKPLACE.QUICK_LINK_LOCKERS', + }, +]; + +/** Whether the Quick Actions card has an action to show */ +export function hasQuickActions( + features: string[], + show_quick_book: boolean, + show_quick_links: boolean, +): boolean { + return ( + (show_quick_book && features.includes('desks')) || + (show_quick_links && + QUICK_LINKS.some((link) => features.includes(link.feature))) + ); +} diff --git a/apps/workplace/src/app/landing-new/landing-new.component.ts b/apps/workplace/src/app/landing-new/landing-new.component.ts new file mode 100644 index 0000000000..7d506e42c1 --- /dev/null +++ b/apps/workplace/src/app/landing-new/landing-new.component.ts @@ -0,0 +1,118 @@ +import { Component, computed, inject, OnDestroy, OnInit } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { AsyncHandler, settingSignal } from '@placeos/common'; +import { FooterMenuComponent } from '../components/footer-menu.component'; +import { TopbarComponent } from '../components/topbar.component'; +import { VirtualConciergeButtonComponent } from '../components/virtual-concierge-button.component'; +import { LandingStateService } from '../landing/landing-state.service'; +import { LandingAvailableNowComponent } from './landing-available-now.component'; +import { LandingColleaguesNewComponent } from './landing-colleagues-new.component'; +import { LandingDeskWeekComponent } from './landing-desk-week.component'; +import { LandingFavouritesNewComponent } from './landing-favourites-new.component'; +import { + availableNowTabs, + hasQuickActions, + landingFeatures, +} from './landing-features'; +import { LandingQuickActionsComponent } from './landing-quick-actions.component'; +import { LandingUpcomingBookingComponent } from './landing-upcoming-booking.component'; + +@Component({ + selector: 'landing-new', + template: ` +
+ @if (!hide_nav) { + + } +
+
+ + + + @if (show_available_now()) { + + } + @if (show_quick_actions()) { + + } + @if (features().includes('desks')) { + + } + + @if (!hide_colleagues()) { + + } +
+
+ +
+ @if (!hide_nav) { + + } +
+
+ `, + styles: [``], + imports: [ + RouterModule, + TopbarComponent, + FooterMenuComponent, + VirtualConciergeButtonComponent, + LandingUpcomingBookingComponent, + // LandingVirtualConciergeComponent, + LandingAvailableNowComponent, + LandingQuickActionsComponent, + LandingDeskWeekComponent, + LandingFavouritesNewComponent, + LandingColleaguesNewComponent, + ], +}) +export class LandingNewComponent + extends AsyncHandler + implements OnInit, OnDestroy +{ + private _state = inject(LandingStateService); + + /** Set from the `hide_nav` URL parameter when the app is embedded */ + public get hide_nav() { + return localStorage.getItem('PlaceOS.hide_nav') === 'true'; + } + public readonly features = landingFeatures(); + public readonly hide_colleagues = settingSignal('hide_colleagues'); + public readonly hide_spaces = settingSignal('hide_landing_spaces'); + public readonly hide_rooms = settingSignal('hide_landing_rooms'); + public readonly show_quick_links = + settingSignal('show_quick_links'); + public readonly show_quick_book = settingSignal('show_quick_book'); + + public readonly show_available_now = computed( + () => + availableNowTabs( + this.features(), + !!this.hide_spaces(), + !!this.hide_rooms(), + ).length > 0, + ); + public readonly show_quick_actions = computed(() => + hasQuickActions( + this.features(), + this.show_quick_book() !== false, + this.show_quick_links() !== false, + ), + ); + + public ngOnInit() { + this.subscription('poll', this._state.pollUpcomingEvents()); + } +} diff --git a/apps/workplace/src/app/landing-new/landing-quick-actions.component.ts b/apps/workplace/src/app/landing-new/landing-quick-actions.component.ts new file mode 100644 index 0000000000..f87ab19ec3 --- /dev/null +++ b/apps/workplace/src/app/landing-new/landing-quick-actions.component.ts @@ -0,0 +1,101 @@ +import { Component, computed, inject } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { RouterModule } from '@angular/router'; +import { settingSignal } from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { AutoAssignedDeskModalComponent } from '../book/desk-flow/auto-assigned-desk-modal.component'; +import { landingFeatures, QUICK_LINKS } from './landing-features'; + +@Component({ + selector: 'landing-quick-actions', + template: ` +
+
+

+ {{ 'APP.WORKPLACE.QUICK_ACTIONS_HEADER' | translate }} +

+
+ @if (can_quick_book()) { + + } + @if (links().length) { +

+ {{ 'APP.WORKPLACE.QUICK_LINKS_HEADER' | translate }} +

+ @for (link of links(); track link.feature) { + + {{ link.icon }} +
{{ link.name | translate }}
+ chevron_right +
+ } + } +
+ `, + imports: [MatRippleModule, RouterModule, IconComponent, TranslatePipe], +}) +export class LandingQuickActionsComponent { + private _dialog = inject(MatDialog); + + public readonly features = landingFeatures(); + /** Show the one-click desk booking. Set to `false` to hide it */ + public readonly show_quick_book = settingSignal('show_quick_book'); + /** Show the booking links. Set to `false` to hide them */ + public readonly show_quick_links = + settingSignal('show_quick_links'); + + public readonly can_quick_book = computed( + () => + this.show_quick_book() !== false && + this.features().includes('desks'), + ); + public readonly links = computed(() => + this.show_quick_links() !== false + ? QUICK_LINKS.filter((link) => + this.features().includes(link.feature), + ) + : [], + ); + + public readonly autoAssignDesk = () => { + // Open the auto-assigned desk modal + const dialog_ref = this._dialog.open(AutoAssignedDeskModalComponent, { + maxWidth: '100vw', + maxHeight: '100vh', + panelClass: 'auto-assigned-desk-modal', + }); + dialog_ref.componentInstance.show_close.set(true); + }; +} diff --git a/apps/workplace/src/app/landing-new/landing-upcoming-booking.component.ts b/apps/workplace/src/app/landing-new/landing-upcoming-booking.component.ts new file mode 100644 index 0000000000..c3cfcae42a --- /dev/null +++ b/apps/workplace/src/app/landing-new/landing-upcoming-booking.component.ts @@ -0,0 +1,704 @@ +import { ComponentType } from '@angular/cdk/portal'; +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + effect, + inject, + Signal, + signal, + untracked, +} from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Router, RouterLink } from '@angular/router'; +import { + BookingDetailsModalComponent, + canEditBooking, + setBookingCheckedIn, +} from '@placeos/bookings'; +import { + AsyncHandler, + Booking, + BuildingLevel, + CalendarEvent, + currentUser, + i18n, + notifyError, + notifySuccess, + OrganisationService, +} from '@placeos/common'; +import { + BindingDirective, + IconComponent, + MapLocateModalComponent, + TranslatePipe, +} from '@placeos/components'; +import { + checkinEventGuest, + EventDetailsModalComponent, + GroupEventDetailsModalComponent, +} from '@placeos/events'; +import { getModule } from '@placeos/ts-client'; +import { differenceInMinutes, format, isSameDay } from 'date-fns'; +import { SpacePipe } from 'libs/events/src/lib/space.pipe'; +import { timer } from 'rxjs'; +import { debounce, distinctUntilChanged } from 'rxjs/operators'; +import { LandingStateService } from '../landing/landing-state.service'; +import { ScheduleStateService } from '../schedule/schedule-state.service'; +import { landingFeatures } from './landing-features'; + +/** Item that the map locate modal shows */ +interface MapLocateTarget { + id: string; + name: string; + map_id: string; + zones: readonly string[]; + level: BuildingLevel; +} + +@Component({ + selector: 'landing-upcoming-booking', + template: ` + @if (nextEvent(); as event) { +
+ @if (room_system_id(); as room_id) { + + + } +
+
+
+
+ {{ 'CALENDAR_EVENT.GROUP_FEATURED' | translate }} +
+
+ @if (isInProgress()) { +
+ schedule +
+ {{ + 'APP.WORKPLACE.UPCOMING_ENDS_IN' + | translate + : { minutes: minutesUntilEnd() } + }} +
+
+ } @else if (minutesUntilStart() >= 0) { +
+ timelapse +
+ {{ + 'APP.WORKPLACE.UPCOMING_STARTS_IN' + | translate + : { minutes: minutesUntilStart() } + }} +
+
+ } +
+
+
+

+ {{ eventTitle() }} +

+
+ event +
{{ eventTimeDisplay() }}
+
+ @if (eventLocation()) { +
+ distance +
{{ eventLocation() }}
+
+ } + @if (attendeeCount(); as count) { +
+ people +
+ {{ + 'CALENDAR_EVENT.ATTENDEES_COUNT' + | translate: { count }: count + }} +
+
+ } +
+
+
+ @if (showCheckin()) { + + } + +
+
+ + +
+ + open_in_new +
+ {{ + 'APP.WORKPLACE.UPCOMING_VIEW_BOOKINGS' + | translate + }} +
+
+
+
+
+ } @else { +
+
+
+
+ {{ 'APP.WORKPLACE.UPCOMING_NONE' | translate }} +
+
+

+ {{ 'APP.WORKPLACE.UPCOMING_CLEAR_TITLE' | translate }} +

+

+ {{ 'APP.WORKPLACE.UPCOMING_CLEAR_MSG' | translate }} +

+ +
+ } + `, + styles: [ + ` + .bg-grad { + background: linear-gradient( + 105deg, + var(--brand-100) 0%, + var(--brand-200) 100% + ); + } + `, + ], + imports: [ + CommonModule, + MatRippleModule, + BindingDirective, + IconComponent, + TranslatePipe, + RouterLink, + MatTooltipModule, + ], +}) +export class LandingUpcomingBookingComponent extends AsyncHandler { + private _state = inject(LandingStateService); + private _schedule = inject(ScheduleStateService); + private _dialog = inject(MatDialog); + private _org = inject(OrganisationService); + private _router = inject(Router); + private _space_pipe = new SpacePipe(this._org); + + public readonly features = landingFeatures(); + public readonly upcomingEvents: Signal<(Booking | CalendarEvent)[]> = + this._state.upcoming_events; + public readonly room_status = signal(''); + public readonly room_booking_start = signal(0); + public readonly room_system_id = signal(''); + private readonly _room_event_key = signal(''); + private readonly _current_time_tick = toSignal(timer(0, 1000), { + initialValue: 0, + }); + + public readonly edit_fn = (i: CalendarEvent) => this._schedule.edit(i); + public readonly edit_booking_fn = (i: Booking) => + this._schedule.editBooking(i); + public readonly remove_fn = (i: Booking | CalendarEvent, s?: boolean) => + this._schedule.remove(i, s); + public readonly end_fn = (i: Booking) => this._schedule.end(i); + + public readonly nextEvent = computed(() => { + const events = this.upcomingEvents(); + return events?.[0]; + }); + + public readonly showCheckin = computed(() => { + const event = this.nextEvent(); + return !( + event instanceof Booking && + event.booking_type === 'parking' && + event.asset_id.startsWith('unallocated') + ); + }); + + public readonly canCheckin = computed(() => { + const event = this.nextEvent(); + const can_checkin = + event instanceof Booking + ? !event.checked_out_at + : event?.extension_data?.shared_event + ? event?.can_check_in + : event?.can_check_in; + return ( + this.showCheckin() && + can_checkin && + (event instanceof Booking || + event?.extension_data?.shared_event || + (this.room_status() && this.room_status() !== 'free')) && + (event.state === 'upcoming' || + event.state === 'started' || + event.state === 'in_progress') && + event.status !== 'declined' + ); + }); + + public readonly checkinDisabled = computed( + () => !this.canCheckin() || this.isCheckedIn(), + ); + + public readonly canEdit = computed(() => { + const event = this.nextEvent(); + if (!event) return false; + if (event instanceof Booking) return canEditBooking(event); + return !event.extension_data?.shared_event; + }); + + public readonly deleteDisabled = computed(() => { + this._current_time_tick(); + const event = this.nextEvent(); + if (!event) return true; + // Bookings can be ended early (sets checked_out_at); has_ended covers + // both the manual-end and scheduled-end cases. + if (event instanceof Booking) return event.has_ended; + return Date.now() >= event.date_end; + }); + + public readonly eventTitle = computed(() => { + const event = this.nextEvent(); + if (!event) return ''; + if (event instanceof Booking) { + return ( + event.title || + event.description || + event.asset_name || + i18n('RESOURCE.BOOKING') + ); + } + return event.title || i18n('RESOURCE.EVENT'); + }); + + public readonly eventLocation = computed(() => { + const event = this.nextEvent(); + if (!event) return ''; + if (event instanceof Booking) { + return event.asset_name || ''; + } + return event.resources?.[0]?.display_name || event.location || ''; + }); + + public readonly eventTimeDisplay = computed(() => { + const event = this.nextEvent(); + if (!event) return ''; + const start = format(event.date, 'h:mm a'); + const end = format(event.date + event.duration * 60 * 1000, 'h:mm a'); + const day = isSameDay(event.date, Date.now()) + ? i18n('COMMON.TODAY') + : format(event.date, 'MMM d'); + return `${start} - ${end} · ${day}`; + }); + + public readonly isInProgress = computed(() => { + const event = this.nextEvent(); + if (!event) return false; + const now = Date.now(); + return now >= event.date && now < event.date_end; + }); + + public readonly minutesUntilStart = computed(() => { + const event = this.nextEvent(); + if (!event) return 0; + return differenceInMinutes(event.date, Date.now()); + }); + + public readonly minutesUntilEnd = computed(() => { + const event = this.nextEvent(); + if (!event) return 0; + const end_time = event.date + event.duration * 60 * 1000; + return differenceInMinutes(end_time, Date.now()); + }); + + private readonly _checked_in = computed(() => { + const event = this.nextEvent(); + if (!event) return true; + if (event instanceof Booking) return event.checked_in; + if (!event.extension_data?.shared_event) { + return ( + this.room_status() !== 'pending' && + this.room_status() !== 'free' && + this._roomBookingMatchesEventStart(event) + ); + } + const user_email = currentUser()?.email?.toLowerCase(); + if (!user_email) return false; + // `checked_in` is not in the EventExtensionData type + const { checked_in } = event.extension_data as { + checked_in?: string[]; + }; + return (checked_in || []).some( + (email) => `${email}`.toLowerCase() === user_email, + ); + }); + + public readonly isCheckedIn = toSignal( + toObservable(this._checked_in).pipe( + distinctUntilChanged(), + debounce((checked_in) => timer(checked_in ? 0 : 300)), + ), + { + initialValue: this._checked_in(), + }, + ); + + constructor() { + super(); + effect(() => { + const events = this._state.upcoming_events(); + const event = events?.[0]; + if ( + !(event instanceof CalendarEvent) || + event.extension_data?.shared_event + ) { + untracked(() => { + this._room_event_key.set(''); + this.room_status.set(''); + this.room_booking_start.set(0); + this.room_system_id.set(''); + }); + return; + } + const event_key = event.id; + untracked(() => { + const same_event = this._room_event_key() === event_key; + this._room_event_key.set(event_key); + if (!same_event) { + this.room_status.set(''); + this.room_booking_start.set(0); + this.room_system_id.set(''); + } + if (!same_event || !this.room_system_id()) { + this._resolveRoomSystem(event); + } + }); + }); + } + + public readonly attendeeCount = computed(() => { + const event = this.nextEvent(); + if (!event) return 0; + if (event instanceof CalendarEvent) { + return event.attendees?.length || 0; + } + return 0; + }); + + public async checkIn() { + const event = this.nextEvent(); + if (!event) return; + + try { + if (event instanceof Booking) { + // Checks in one occurrence when the booking is recurring + await setBookingCheckedIn(event, true); + } else if (event.extension_data?.shared_event) { + const user_email = currentUser()?.email; + if (!user_email) throw new Error('Missing current user email'); + await checkinEventGuest(event.id, user_email, true, { + system_id: event.system?.id, + }); + } else { + const room_id = + this.room_system_id() || + event.space?.id || + event.system?.id; + const mod = getModule(room_id, 'Bookings'); + if (!mod) throw new Error('Missing bookings module'); + await mod.execute('checkin', [Math.floor(event.date / 1000)]); + } + notifySuccess(i18n('BOOKINGS.CHECK_IN_SUCCESS')); + this._state.refreshUpcomingEvents(); + } catch (error) { + notifyError(i18n('BOOKINGS.CHECK_IN_ERROR')); + } + } + + public viewDetails() { + this.timeout('view_details', () => { + const event = this.nextEvent(); + if (!event) return; + + if (event instanceof Booking) { + const view_component: ComponentType = + event.booking_type === 'group-event' + ? GroupEventDetailsModalComponent + : BookingDetailsModalComponent; + const data = { + booking: + event.booking_type === 'group-event' + ? { booking: event, concierge: false } + : event, + edit_fn: this.edit_booking_fn, + remove_fn: this.remove_fn, + end_fn: this.end_fn, + refresh_fn: () => this._state.refreshUpcomingEvents(), + }; + this._dialog.open(view_component, { data }); + } else if (event instanceof CalendarEvent) { + if (event.extension_data?.shared_event) { + this._dialog.open(GroupEventDetailsModalComponent, { + data: { + event: event, + edit_fn: this.edit_fn, + remove_fn: this.remove_fn, + concierge: false, + }, + }); + return; + } + this._dialog.open(EventDetailsModalComponent, { + data: { + event: event, + edit_fn: this.edit_fn, + remove_fn: this.remove_fn, + }, + }); + } + }); + } + + public edit() { + const event = this.nextEvent(); + if (!event || !this.canEdit()) return; + event instanceof CalendarEvent + ? this.edit_fn(event) + : this.edit_booking_fn(event); + } + + public remove() { + const event = this.nextEvent(); + if (this.deleteDisabled()) return; + this.remove_fn(event); + } + + public viewDirections() { + const event = this.nextEvent(); + if (!event) return; + + let itemData: MapLocateTarget | undefined; + let zones: readonly string[] = []; + + if (event instanceof Booking) { + zones = event.zones || []; + itemData = { + id: event.asset_id, + name: event.asset_name || event.description, + map_id: event.extension_data?.map_id || event.asset_id, + zones, + level: this._org.levelWithID([...zones]), + }; + } else if (event instanceof CalendarEvent) { + const resource = { + ...(event.resources?.[0] || {}), + ...(event.system || {}), + }; + zones = resource?.zones || []; + itemData = { + id: resource?.id, + name: resource?.display_name || resource?.name || event.title, + map_id: resource?.map_id || resource?.id, + zones, + level: this._org.levelWithID([...zones]), + }; + } + + if (itemData && itemData.id) { + this._dialog.open(MapLocateModalComponent, { + maxWidth: '95vw', + maxHeight: '95vh', + data: { item: itemData }, + }); + } else { + notifyError(i18n('Unable to validate location for booking.')); + } + } + + public findColleagues() { + this._router.navigate(['/explore']); + } + + private async _resolveRoomSystem(event: CalendarEvent) { + const lookup_id = + event.system?.id || + event.system?.email || + event.resources?.[0]?.id || + event.resources?.[0]?.email || + ''; + if (!lookup_id) return; + const space = await this._space_pipe.transform(lookup_id); + if (this.nextEvent()?.id !== event.id) return; + this.room_system_id.set(space?.id || event.system?.id || ''); + } + + private _roomBookingMatchesEventStart(event: CalendarEvent) { + const booking_start = this.room_booking_start(); + if (!booking_start) return false; + const booking_start_ms = + booking_start < 1_000_000_000_000 + ? booking_start * 1000 + : booking_start; + return Math.abs(booking_start_ms - event.date) <= 60 * 1000; + } +} diff --git a/apps/workplace/src/app/landing-new/landing-virtual-concierge.component.ts b/apps/workplace/src/app/landing-new/landing-virtual-concierge.component.ts new file mode 100644 index 0000000000..069c39f9ec --- /dev/null +++ b/apps/workplace/src/app/landing-new/landing-virtual-concierge.component.ts @@ -0,0 +1,244 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + effect, + ElementRef, + inject, + signal, + viewChild, +} from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { RouterModule } from '@angular/router'; +import { settingSignal, SettingsService, userSignal } from '@placeos/common'; +import { IconComponent } from '@placeos/components'; + +@Component({ + selector: 'landing-virtual-concierge', + template: ` + + @if (!is_fullscreen()) { +
+
+
+
+ +
+
+

+ Welcome {{ user().name }}, it's + {{ time() | date: 'fullDate' }} +

+

+ I'm Ben, your virtual concierge +

+
+
+ + + + + +
+

Try asking me:

+
+ @for (prompt of prompts(); track prompt) { + + } +
+
+ + +
+

Quick actions:

+
+ @if (features().includes('spaces')) { + + meeting_room +
Book Room
+
+ } + @if (features().includes('desks')) { + + desk +
Book Desk
+
+ } + @if (features().includes('parking')) { + + directions_car +
Book Parking
+
+ } + @if (features().includes('lockers')) { + + lock +
Book Locker
+
+ } +
+
+
+ } + + + @if (is_fullscreen()) { +
+
+ + +
+
+
+
+ +
+
+

+ Welcome {{ user().name }}, it's + {{ time() | date: 'fullDate' }} +

+

+ I'm Ben, your virtual concierge +

+
+
+ + + + + +
+

Try asking me:

+
+ @for (prompt of prompts(); track prompt) { + + } +
+
+
+
+
+ } + `, + styles: [``], + imports: [ + CommonModule, + MatFormFieldModule, + MatInputModule, + MatRippleModule, + RouterModule, + IconComponent, + ], +}) +export class LandingVirtualConciergeComponent { + private readonly _settings = inject(SettingsService); + + public readonly user = userSignal(); + public readonly time = signal(Date.now()); + public readonly is_fullscreen = signal(false); + public readonly features = settingSignal('features', []); + + private readonly _fullscreen_input = + viewChild>('fullscreenInput'); + + public readonly prompts = computed(() => { + const config_prompts = this._settings.signal( + 'virtual_concierge.prompts', + )(); + return config_prompts && Array.isArray(config_prompts) + ? config_prompts + : [ + 'Book a meeting room tomorrow at 2pm for 4 people', + 'Find me an available desk near a window', + 'Reserve parking for Friday morning', + ]; + }); + + constructor() { + effect(() => { + if (this.is_fullscreen() && this._fullscreen_input()) { + setTimeout( + () => this._fullscreen_input()?.nativeElement.focus(), + 100, + ); + } + }); + } + + public openFullscreen() { + this.is_fullscreen.set(true); + } + + public closeFullscreen() { + this.is_fullscreen.set(false); + } +} diff --git a/apps/workplace/src/app/landing/landing-favourites.component.ts b/apps/workplace/src/app/landing/landing-favourites.component.ts index 357200ada5..54d0977ad5 100644 --- a/apps/workplace/src/app/landing/landing-favourites.component.ts +++ b/apps/workplace/src/app/landing/landing-favourites.component.ts @@ -1,11 +1,12 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { Router } from '@angular/router'; -import { BookingFormService, FAV_PARKING_KEY } from '@placeos/bookings'; +import { BookingFormService } from '@placeos/bookings'; import { AsyncHandler, BookingType, OrganisationService, + SETTING_KEYS, SettingsService, Space, } from '@placeos/common'; @@ -15,7 +16,6 @@ import { showMetadata } from '@placeos/ts-client'; import { CommonModule } from '@angular/common'; import { MatRippleModule } from '@angular/material/core'; import { MatMenuModule } from '@angular/material/menu'; -import { FAV_DESK_KEY, FAV_LOCKER_KEY } from '@placeos/bookings'; import { AuthenticatedImageDirective, IconComponent, @@ -298,19 +298,29 @@ export class LandingFavouritesComponent extends AsyncHandler implements OnInit { public readonly assets = signal([]); public get space_ids() { - return this._settings.get('favourite_spaces') || EMPTY; + return ( + this._settings.get(SETTING_KEYS.FAVORITE_ROOMS) || EMPTY + ); } public get desks() { - return this._settings.get(FAV_DESK_KEY) || EMPTY; + return ( + this._settings.get(SETTING_KEYS.FAVORITE_DESKS) || EMPTY + ); } public get parking_spaces() { - return this._settings.get(FAV_PARKING_KEY) || EMPTY; + return ( + this._settings.get( + SETTING_KEYS.FAVORITE_PARKING_SPACES, + ) || EMPTY + ); } public get locker_banks() { - return this._settings.get(FAV_LOCKER_KEY) || EMPTY; + return ( + this._settings.get(SETTING_KEYS.FAVORITE_LOCKERS) || EMPTY + ); } public level(space: Space) { @@ -361,19 +371,19 @@ export class LandingFavouritesComponent extends AsyncHandler implements OnInit { id: string, ) { let fav_list = this.space_ids; - let key = 'favourite_spaces'; + let key = SETTING_KEYS.FAVORITE_ROOMS; switch (type) { case 'desk': fav_list = this.desks; - key = FAV_DESK_KEY; + key = SETTING_KEYS.FAVORITE_DESKS; break; case 'parking': fav_list = this.parking_spaces; - key = FAV_PARKING_KEY; + key = SETTING_KEYS.FAVORITE_PARKING_SPACES; break; case 'locker': fav_list = this.locker_banks; - key = FAV_LOCKER_KEY; + key = SETTING_KEYS.FAVORITE_LOCKERS; break; } this._settings.saveUserSetting( diff --git a/apps/workplace/src/app/landing/landing-state.service.ts b/apps/workplace/src/app/landing/landing-state.service.ts index d718479e01..ad8607d5c9 100644 --- a/apps/workplace/src/app/landing/landing-state.service.ts +++ b/apps/workplace/src/app/landing/landing-state.service.ts @@ -19,13 +19,16 @@ import { import { AsyncHandler, + Booking, BookingRuleset, BuildingLevel, currentUser, filterResourcesFromRules, HashMap, + i18n, OrganisationService, SettingsService, + Space, StaffUser, unique, User, @@ -55,26 +58,35 @@ export class LandingStateService extends AsyncHandler { private _level_occupancy = signal([]); private _occupancy_binding: PlaceVariableBinding; private _booking_rules = signal([]); - private _space_list = signal([]); + private _space_list = signal([]); private _space_statuses = signal([]); private _search_results = signal([]); + // `any` stays here: explore.component.ts reads event and booking fields + // from this list with no type guard. private _upcoming_events = signal([]); - private _filtered_spaces = computed(() => - filterResourcesFromRules( - this._space_list(), - { - date: Date.now(), - duration: 60, - host: currentUser(), - resource: null, - }, - this._booking_rules(), - ), + private _filtered_spaces = computed( + () => + filterResourcesFromRules( + this._space_list(), + { + date: Date.now(), + duration: 60, + host: currentUser(), + resource: null, + }, + this._booking_rules(), + ) as Space[], ); + /** Bookable spaces of the active building */ + public readonly space_list = this._space_list.asReadonly(); + /** + * Spaces that the user can book and that have a live status of free. + * The statuses have the same order as the filtered spaces. + */ public readonly free_space_list = computed(() => - (this._space_list() || []) + this._filtered_spaces() .filter((_, idx) => this._space_statuses()[idx] === 'free') .sort((a, b) => a.capacity - b.capacity), ); @@ -136,7 +148,9 @@ export class LandingStateService extends AsyncHandler { effect((onCleanup) => { const { search } = this._options(); const timeout = setTimeout(async () => { - this._loading.set('Loading users...'); + this._loading.set( + i18n('APP.WORKPLACE.COLLEAGUE_SEARCH_LOADING'), + ); const results = search ? await this.search_fn(search).catch(() => []) : []; @@ -151,9 +165,13 @@ export class LandingStateService extends AsyncHandler { .filtered_bookings() .filter( (i) => - i.status !== 'cancelled' && - i.type !== 'cancelled' && - i.state !== 'done' && + !i.deleted && + !['cancelled', 'declined', 'ended'].includes( + i.status, + ) && + (i instanceof Booking + ? !i.is_done + : i.state !== 'done') && isSameDay(i.date, Date.now()), ), ); @@ -216,6 +234,17 @@ export class LandingStateService extends AsyncHandler { this.updateContacts(); } + public async addContacts(user_list: User[]) { + let users = [...this._contacts(), ...user_list]; + users = unique(users, 'email'); + await updateMetadata(currentUser().id, { + name: 'contacts', + description: 'Contacts for the User', + details: users, + }); + this.updateContacts(); + } + public async removeContact(user: User) { let users = [...this._contacts()]; users = users.filter((u) => u.email !== user.email); diff --git a/apps/workplace/src/app/landing/landing-upcoming.component.ts b/apps/workplace/src/app/landing/landing-upcoming.component.ts index aed94288e1..b732f62fd5 100644 --- a/apps/workplace/src/app/landing/landing-upcoming.component.ts +++ b/apps/workplace/src/app/landing/landing-upcoming.component.ts @@ -16,6 +16,7 @@ import { i18n, notifyError, notifySuccess, + responseErrorMessage, SettingsService, } from '@placeos/common'; import { openConfirmModal, TranslatePipe } from '@placeos/components'; @@ -156,9 +157,10 @@ export class LandingUpcomingComponent } public editBooking(event: Booking) { + const booking_type = `${event.booking_type || ''}`.trim() || event.type; this._router.navigate(['/book', `${event.type}`]); - this._booking_form.newForm(event.booking_type, event); - if (event.booking_type === 'visitor') return; + this._booking_form.newForm(booking_type as any, event); + if (booking_type === 'visitor') return; setTimeout(() => { this._booking_form.model.update((m) => ({ ...m, @@ -193,11 +195,8 @@ export class LandingUpcomingComponent if (resp.reason !== 'done') return; resp.loading(i18n('APP.WORKPLACE.SCHEDULE_REMOVE_LOADING')); - const remove_result = ( - item instanceof CalendarEvent && !item.from_bookings - ? removeEvent - : removeBooking - )(item.id, { + // `instance` and `start_time` are only read for bookings + const options = { calendar: this._settings.get('app.events.use_bookings') ? null : currentUser()?.email, @@ -206,14 +205,21 @@ export class LandingUpcomingComponent start_time: (item as any).instance ? (item as any).booking_start : undefined, - }) as any; + }; + const remove_result = ( + item instanceof CalendarEvent && !item.from_bookings + ? removeEvent + : removeBooking + )(item.id, options) as any; await ( remove_result?.then instanceof Function ? remove_result : remove_result.toPromise() - ).catch((e) => { + ).catch(async (e) => { notifyError( - i18n('APP.WORKPLACE.SCHEDULE_REMOVE_ERROR', { error: e }), + i18n('APP.WORKPLACE.SCHEDULE_REMOVE_ERROR', { + error: await responseErrorMessage(e), + }), ); resp.close(); throw e; @@ -240,8 +246,12 @@ export class LandingUpcomingComponent if (resp.reason !== 'done') return; resp.loading(i18n('APP.WORKPLACE.SCHEDULE_END_LOADING')); - await setBookingCheckedIn(item, false).catch((e) => { - notifyError(i18n('APP.WORKPLACE.SCHEDULE_END_ERROR', { error: e })); + await setBookingCheckedIn(item, false).catch(async (e) => { + notifyError( + i18n('APP.WORKPLACE.SCHEDULE_END_ERROR', { + error: await responseErrorMessage(e), + }), + ); resp.close(); throw e; }); diff --git a/apps/workplace/src/app/schedule/schedule-day-view.component.ts b/apps/workplace/src/app/schedule/schedule-day-view.component.ts new file mode 100644 index 0000000000..b1e585ac75 --- /dev/null +++ b/apps/workplace/src/app/schedule/schedule-day-view.component.ts @@ -0,0 +1,641 @@ +import { ComponentType } from '@angular/cdk/portal'; +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + effect, + ElementRef, + inject, + input, + OnInit, + signal, + untracked, + viewChild, +} from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { + BookingDetailsModalComponent, + bookingLocationString, + isInWaitlistWeek, + visitorDisplayNameFor, +} from '@placeos/bookings'; +import { + AsyncHandler, + Booking, + BOOKING_TYPE_COLORS, + CalendarEvent, + OrganisationService, +} from '@placeos/common'; +import { + EventDetailsModalComponent, + GroupEventDetailsModalComponent, +} from '@placeos/events'; +import { UserPipe } from '@placeos/users'; +import { + format, + isSameDay, + setHours, + setMinutes, + startOfDay, +} from 'date-fns'; +import { + bookedForLabel, + isBookingForOtherUser, + itemEnd, + overlapsDay, + ScheduleStateService, +} from './schedule-state.service'; + +interface TimeSlot { + hour: number; + label: string; +} + +interface PositionedBooking { + booking: Booking | CalendarEvent; + top: number; // percentage + height: number; // percentage + left: number; // percentage + width: number; // percentage + column: number; + totalColumns: number; +} + +@Component({ + selector: `schedule-day-view`, + template: ` +
+
+
+

+ {{ date() | date: 'EEEE, MMMM d, yyyy' }} +

+
+ {{ dayBookings().length }} booking{{ + dayBookings().length !== 1 ? 's' : '' + }} +
+
+
+ +
+ @for (slot of timeSlots(); track slot.hour) { +
+
+ {{ slot.label }} +
+
+ } +
+ + +
+ +
+ @for (slot of timeSlots(); track slot.hour) { +
+
+
+
+ } +
+ + + @if (currentTimePosition() !== null) { +
+
+
+
+ } + + +
+ @for ( + item of positionedBookings(); + track item.booking.id + ) { + + } +
+
+
+
+
+ `, + styles: [``], + imports: [CommonModule, MatRippleModule, MatTooltipModule, UserPipe], +}) +export class ScheduleDayViewComponent extends AsyncHandler implements OnInit { + private _dialog = inject(MatDialog); + private _state = inject(ScheduleStateService); + private _org = inject(OrganisationService); + + public readonly date = input(Date.now()); + public readonly bookings = input<(Booking | CalendarEvent)[]>([]); + public readonly loading = input(false); + public readonly colors = BOOKING_TYPE_COLORS; + private _changed = signal(0); + + private readonly scrollContainer = + viewChild>('scrollContainer'); + private readonly currentTimeMarker = + viewChild>('currentTimeMarker'); + + constructor() { + super(); + // Scroll to the current time one time for each date change. The + // marker position changes every 10 seconds, so it is not tracked here: + // the view must not move after the user scrolls. + effect(() => { + this.date(); + const marker = this.currentTimeMarker()?.nativeElement; + const container = this.scrollContainer()?.nativeElement; + const position = untracked(() => this.currentTimePosition()); + + if (position !== null && marker && container) { + // Wait a tick for the view to render + setTimeout(() => { + marker.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + }, 100); + } + }); + } + + public ngOnInit() { + this.interval('time', () => this._changed.set(Date.now()), 10 * 1000); + } + + // Configuration + private readonly START_HOUR = 0; // Midnight + private readonly END_HOUR = 23; // 11 PM + private readonly HOUR_HEIGHT = 64; // pixels + + public readonly timeSlots = computed(() => { + const slots: TimeSlot[] = []; + for (let hour = this.START_HOUR; hour <= this.END_HOUR; hour++) { + const date = setHours(setMinutes(new Date(), 0), hour); + slots.push({ + hour, + label: format(date, 'h a'), + }); + } + return slots; + }); + + public readonly gridHeight = computed(() => { + return (this.END_HOUR - this.START_HOUR + 1) * this.HOUR_HEIGHT; + }); + + public readonly isToday = computed(() => { + return isSameDay(this.date(), Date.now()); + }); + + public readonly currentTimePosition = computed(() => { + if (!this.isToday()) return null; + this._changed(); + + const now = Date.now(); + const dayStart = setHours( + setMinutes(startOfDay(this.date()), 0), + this.START_HOUR, + ).valueOf(); + const dayEnd = setHours( + setMinutes(startOfDay(this.date()), 0), + this.END_HOUR + 1, + ).valueOf(); + + // Only show if current time is within visible range + if (now < dayStart || now > dayEnd) return null; + + const totalMinutes = (dayEnd - dayStart) / (1000 * 60); + const currentOffset = (now - dayStart) / (1000 * 60); + const position = (currentOffset / totalMinutes) * 100; + + return position; + }); + + public readonly dayBookings = computed(() => { + const targetDate = this.date(); + return (this.bookings() || []).filter((booking) => + overlapsDay(booking, targetDate), + ); + }); + + public readonly positionedBookings = computed(() => { + const bookings = this.dayBookings(); + if (!bookings.length) return []; + + // Sort bookings by start time, then by duration (longer first) + const sorted = [...bookings].sort((a, b) => { + if (a.date !== b.date) return a.date - b.date; + return b.duration - a.duration; + }); + + // Calculate overlaps and positions + const positioned: PositionedBooking[] = []; + const columns: (Booking | CalendarEvent)[][] = []; + + for (const booking of sorted) { + // Find the first column where this booking doesn't overlap + let columnIndex = 0; + let placed = false; + + while (!placed) { + if (!columns[columnIndex]) { + columns[columnIndex] = []; + } + + const hasOverlap = columns[columnIndex].some((existing) => + this.checkOverlap(booking, existing), + ); + + if (!hasOverlap) { + columns[columnIndex].push(booking); + placed = true; + } else { + columnIndex++; + } + } + } + + // Calculate positions for each booking + for (const booking of sorted) { + // Find which column this booking is in + let columnIndex = 0; + for (let i = 0; i < columns.length; i++) { + if (columns[i].includes(booking)) { + columnIndex = i; + break; + } + } + + // Find how many columns overlap with this booking + let totalColumns = 1; + for (let i = 0; i < columns.length; i++) { + if ( + columns[i].some( + (b) => this.checkOverlap(booking, b) || b === booking, + ) + ) { + totalColumns = Math.max(totalColumns, i + 1); + } + } + + const position = this.calculatePosition( + booking, + columnIndex, + totalColumns, + ); + positioned.push(position); + } + + return positioned; + }); + + private checkOverlap( + a: Booking | CalendarEvent, + b: Booking | CalendarEvent, + ): boolean { + return a.date < itemEnd(b) && itemEnd(a) > b.date; + } + + private calculatePosition( + booking: Booking | CalendarEvent, + column: number, + totalColumns: number, + ): PositionedBooking { + const dayStart = setHours( + setMinutes(startOfDay(this.date()), 0), + this.START_HOUR, + ).valueOf(); + const dayEnd = setHours( + setMinutes(startOfDay(this.date()), 0), + this.END_HOUR + 1, + ).valueOf(); + const totalMinutes = (dayEnd - dayStart) / (1000 * 60); + + // Clamp booking times to visible range + const bookingStart = Math.max(booking.date, dayStart); + const bookingEnd = Math.min(itemEnd(booking), dayEnd); + + const startOffset = (bookingStart - dayStart) / (1000 * 60); + const duration = (bookingEnd - bookingStart) / (1000 * 60); + + const top = (startOffset / totalMinutes) * 100; + const height = Math.max((duration / totalMinutes) * 100, 1); // Minimum 1% + + // Calculate horizontal position with small gaps + const gap = 0.5; // 0.5% gap between columns + const availableWidth = 100 - gap * (totalColumns - 1); + const columnWidth = availableWidth / totalColumns; + const left = column * (columnWidth + gap); + const width = columnWidth; + + return { + booking, + top, + height, + left, + width, + column, + totalColumns, + }; + } + + public type(booking: Booking | CalendarEvent) { + if (booking instanceof Booking) return booking.booking_type; + return booking.extension_data?.shared_event ? 'group-event' : 'event'; + } + + public bookingStatus( + booking: Booking | CalendarEvent, + ): 'approved' | 'tentative' | 'declined' | 'waitlisted' | null { + const status = booking.status; + if ( + status === 'tentative' && + booking instanceof Booking && + booking.booking_type === 'parking' && + isInWaitlistWeek(booking.date, this._org.building?.timezone) + ) { + return 'waitlisted'; + } + return status === 'approved' || + status === 'tentative' || + status === 'declined' + ? status + : null; + } + + public statusLabel( + status: 'approved' | 'tentative' | 'declined' | 'waitlisted', + ) { + return status.charAt(0).toUpperCase() + status.slice(1); + } + + public statusColor( + status: 'approved' | 'tentative' | 'declined' | 'waitlisted', + ) { + if (status === 'approved') return 'var(--success)'; + if (status === 'waitlisted') return 'var(--info)'; + if (status === 'tentative') return 'var(--warn)'; + return 'var(--error)'; + } + + public visitorName(booking: Booking | CalendarEvent): string { + // Only visitor bookings have a visitor name; for other types this + // would fall back to the raw `asset_id` (e.g. unallocated parking). + if (booking instanceof Booking && booking.booking_type === 'visitor') { + return visitorDisplayNameFor(booking); + } + return ''; + } + + public location(booking: Booking | CalendarEvent): string { + return bookingLocationString(booking, this._org); + } + + public backgroundColor(booking: Booking | CalendarEvent) { + const color = this.colors[this.type(booking)][0]; + return this.isBookingForOtherUser(booking) ? `${color}80` : color; + } + + public isBookingForOtherUser = isBookingForOtherUser; + public bookedForLabel = bookedForLabel; + + public viewBooking(bkn: CalendarEvent | Booking) { + this._dialog.closeAll(); + if (bkn instanceof CalendarEvent) { + if (bkn.extension_data?.shared_event) { + this._dialog.open(GroupEventDetailsModalComponent, { + data: { + event: bkn, + edit_fn: (i) => this._state.edit(i), + remove_fn: (i, t) => this._state.remove(i, t), + concierge: false, + }, + }); + } else { + this._dialog.open(EventDetailsModalComponent, { + data: { + event: bkn, + edit_fn: (i) => this._state.edit(i), + remove_fn: (i, t) => this._state.remove(i, t), + }, + }); + } + } else { + const view_component: ComponentType = + bkn.booking_type === 'group-event' + ? GroupEventDetailsModalComponent + : BookingDetailsModalComponent; + const data = { + booking: + bkn.booking_type === 'group-event' + ? { booking: bkn, concierge: false } + : bkn, + edit_fn: (i) => this._state.editBooking(i), + remove_fn: (i, t) => this._state.remove(i, t), + end_fn: (i) => this._state.end(i), + refresh_fn: () => this._state.triggerPoll(), + }; + this._dialog.open(view_component, { data }); + } + } +} diff --git a/apps/workplace/src/app/schedule/schedule-filter-card.component.ts b/apps/workplace/src/app/schedule/schedule-filter-card.component.ts index a4fd676655..162db11edf 100644 --- a/apps/workplace/src/app/schedule/schedule-filter-card.component.ts +++ b/apps/workplace/src/app/schedule/schedule-filter-card.component.ts @@ -1,12 +1,24 @@ import { CommonModule } from '@angular/common'; -import { Component, inject } from '@angular/core'; +import { Component, computed, inject, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatBottomSheetRef } from '@angular/material/bottom-sheet'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatRippleModule } from '@angular/material/core'; -import { SettingsService } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { ScheduleStateService } from './schedule-state.service'; +import { + Booking, + BOOKING_TYPE_COLORS, + CalendarEvent, + SettingsService, +} from '@placeos/common'; +import { + IconComponent, + SettingsToggleComponent, + TranslatePipe, +} from '@placeos/components'; +import { + isBookingForOtherUser, + ScheduleStateService, +} from './schedule-state.service'; @Component({ selector: 'schedule-filter-card', @@ -22,173 +34,47 @@ import { ScheduleStateService } from './schedule-state.service'; -
- @if (hasFeature('spaces')) { - - } - @if (hasFeature('desks')) { - - } - @if (hasFeature('parking') || hasFeature('parking-requests')) { - - } - @if (hasFeature('visitor-invite')) { - - } - @if (hasFeature('lockers')) { - + + } } - @if (hasFeature('group-events')) { - - } +
+ -
+
-
- } - @if ( - filters().shown_types?.includes('desk') && - hasFeature('desks') - ) { -
-
- {{ 'RESOURCE.DESKS' | translate }} -
- -
- } - @if ( - filters().shown_types?.includes('parking') && - (hasFeature('parking') || hasFeature('parking-requests')) - ) { -
-
- {{ 'RESOURCE.PARKING' | translate }} +
{{ item.name | translate }}
+
+ {{ counts()[item.type] || 0 }} +
+
- -
+ } } - @if ( - filters().shown_types?.includes('visitor') && - hasFeature('visitor-invite') - ) { + @if (filters()?.show_bookings_for_others) {
-
- {{ 'RESOURCE.VISITORS' | translate }} -
- -
- } - @if ( - filters().shown_types?.includes('locker') && - hasFeature('lockers') - ) { -
-
- {{ 'RESOURCE.LOCKERS' | translate }} -
-
+
- close - -
- } - @if ( - filters().shown_types?.includes('group-event') && - hasFeature('group-events') - ) { -
-
- {{ 'RESOURCE.EVENTS' | translate }} + {{ counts()['bookings-for-others'] || 0 }}
@@ -141,120 +74,67 @@ import { ScheduleStateService } from './schedule-state.service'; }
-
+
- @if ( - filters().shown_types?.includes('event') && hasFeature('spaces') - ) { -
-
{{ 'RESOURCE.ROOMS' | translate }}
- -
- } - @if ( - filters().shown_types?.includes('desk') && hasFeature('desks') - ) { -
-
{{ 'RESOURCE.DESKS' | translate }}
- -
- } - @if ( - filters().shown_types?.includes('parking') && - (hasFeature('parking') || hasFeature('parking-requests')) - ) { -
-
{{ 'RESOURCE.PARKING' | translate }}
- -
+
{{ item.name | translate }}
+
+ {{ counts()[item.type] || 0 }} +
+ +
+ } } - @if ( - filters().shown_types?.includes('visitor') && - hasFeature('visitor-invite') - ) { + @if (filters()?.show_bookings_for_others) {
-
{{ 'RESOURCE.VISITORS' | translate }}
- -
- } - @if ( - filters().shown_types?.includes('locker') && - hasFeature('lockers') - ) { -
-
{{ 'RESOURCE.LOCKERS' | translate }}
-
+
- close - -
- } - @if ( - filters().shown_types?.includes('group-event') && - hasFeature('group-events') - ) { -
-
{{ 'RESOURCE.EVENTS' | translate }}
+ {{ counts()['bookings-for-others'] || 0 }} +
@@ -271,14 +151,57 @@ export class ScheduleFiltersComponent { private _settings = inject(SettingsService); public readonly filters = this._state.filters; + public readonly bookings = input<(Booking | CalendarEvent)[]>([]); + public readonly features = settingSignal('features', []); + + public readonly counts = computed(() => { + const mapping: Record = {}; + const bkn_list = this.bookings() || []; + for (const bkn of bkn_list) { + if (bkn instanceof CalendarEvent) { + const type = bkn.extension_data?.shared_event + ? 'group-event' + : 'event'; + mapping[type] = (mapping[type] || 0) + 1; + } else { + const type = bkn.booking_type; + mapping[type] = (mapping[type] || 0) + 1; + if (isBookingForOtherUser(bkn)) { + mapping['bookings-for-others'] = + (mapping['bookings-for-others'] || 0) + 1; + } + } + } + return mapping; + }); + + public readonly feature_list = [ + { type: 'desk', feat: 'desks', name: 'RESOURCE.DESKS' }, + { type: 'event', feat: 'spaces', name: 'RESOURCE.MEETINGS' }, + { + type: 'parking', + feat: ['parking', 'parking-requests'], + name: 'RESOURCE.PARKING', + }, + { type: 'visitor', feat: 'visitor-invite', name: 'RESOURCE.VISITORS' }, + { type: 'locker', feat: 'lockers', name: 'RESOURCE.LOCKERS' }, + { type: 'group-event', feat: 'group-events', name: 'RESOURCE.EVENTS' }, + ]; public readonly toggleType = (t, c = false) => this._state.toggleType(t, c); + public readonly toggleBookingsForOthers = () => + this._state.toggleBookingsForOthers(); - public hasFeature(feature: string) { - return this._settings.get('app.features')?.includes(feature); + public hasFeature(feature: string | string[]) { + const features = this.features() || []; + if (Array.isArray(feature)) { + return feature.some((f) => features.includes(f)); + } + return features.includes(feature); } public openFilters() { - this._sheet.open(ScheduleFilterCardComponent); + const ref = this._sheet.open(ScheduleFilterCardComponent); + ref.instance.bookings.set(this.bookings()); } } diff --git a/apps/workplace/src/app/schedule/schedule-list-view.component.ts b/apps/workplace/src/app/schedule/schedule-list-view.component.ts new file mode 100644 index 0000000000..05cd00d94f --- /dev/null +++ b/apps/workplace/src/app/schedule/schedule-list-view.component.ts @@ -0,0 +1,136 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, input } from '@angular/core'; +import { BookingCardComponent } from '@placeos/bookings'; +import { Booking, CalendarEvent } from '@placeos/common'; +import { TranslatePipe } from '@placeos/components'; +import { EventCardComponent } from '@placeos/events'; +import { + addDays, + differenceInCalendarDays, + format, + isSameDay, + startOfDay, +} from 'date-fns'; +import { overlapsDay, ScheduleStateService } from './schedule-state.service'; + +interface ScheduleDateBlock { + id: string; + date: number; + bookings: (Booking | CalendarEvent)[]; + is_today: boolean; +} + +/** Upper bound for the number of days in the list */ +const MAX_LIST_DAYS = 366; + +@Component({ + selector: `schedule-list-view`, + template: ` + @if (booking_dates()?.length) { + @for (date_block of booking_dates(); track date_block.date) { +

+ {{ date_block.date | date: 'EEE dd LLL yyyy' }} + @if (date_block.is_today) { + ({{ 'COMMON.TODAY' | translate }}) + } +

+ @for (item of date_block.bookings; track item.id) { + @if (isEvent(item)) { + + } @else { + + @if (item.status === 'cancelled') { + + {{ 'COMMON.TYPE_CANCELLED' | translate }} + + } + + } + } + } + } @else { +
+ +

+ {{ 'APP.WORKPLACE.SCHEDULE_EMPTY' | translate }} + @if (end_date() && !isSameDayCheck(date(), end_date())) { + {{ date() | date: 'dd LLL yyyy' }} - + {{ end_date() | date: 'dd LLL yyyy' }} + } @else { + {{ date() | date: 'EEEE, dd LLL yyyy' }} + } +

+
+ } + `, + styles: [``], + imports: [ + CommonModule, + TranslatePipe, + EventCardComponent, + BookingCardComponent, + ], +}) +export class ScheduleListViewComponent { + private _state = inject(ScheduleStateService); + public readonly date = input(Date.now()); + public readonly end_date = input(null); + public readonly bookings = input<(Booking | CalendarEvent)[]>([]); + public readonly loading = input(false); + public readonly isEvent = ( + i: Booking | CalendarEvent, + ): i is CalendarEvent => i instanceof CalendarEvent; + public readonly isSameDayCheck = (a: number, b: number) => isSameDay(a, b); + + public readonly edit_fn = (i: CalendarEvent) => this._state.edit(i); + public readonly edit_booking_fn = (i: Booking) => + this._state.editBooking(i); + public readonly remove_fn = (i: Booking | CalendarEvent, t?: boolean) => + this._state.remove(i, t); + public readonly end_fn = (i: Booking) => this._state.end(i); + public readonly refresh_fn = () => this._state.triggerPoll(); + + /** Days in the selected date or date range that have bookings */ + public readonly booking_dates = computed(() => { + if (this.loading()) return []; + const bookings = [...(this.bookings() || [])].sort( + (a, b) => a.date - b.date, + ); + const start = startOfDay(this.date()); + const end = this.end_date(); + // One day when there is no range end + const day_count = Math.min( + MAX_LIST_DAYS, + end ? Math.max(1, differenceInCalendarDays(end, start) + 1) : 1, + ); + const list: ScheduleDateBlock[] = []; + for (let i = 0; i < day_count; i++) { + const day = addDays(start, i).valueOf(); + // A multi-day booking is listed on each day that it covers + const day_bookings = bookings.filter((_) => overlapsDay(_, day)); + if (!day_bookings.length) continue; + list.push({ + id: format(day, 'yyyy-MM-dd'), + date: day, + bookings: day_bookings, + is_today: isSameDay(day, Date.now()), + }); + } + return list; + }); +} diff --git a/apps/workplace/src/app/schedule/schedule-sidebar.component.ts b/apps/workplace/src/app/schedule/schedule-sidebar.component.ts index c988aa8f98..d14d7a4ab6 100644 --- a/apps/workplace/src/app/schedule/schedule-sidebar.component.ts +++ b/apps/workplace/src/app/schedule/schedule-sidebar.component.ts @@ -1,5 +1,12 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, effect, inject, OnInit } from '@angular/core'; +import { + Component, + computed, + effect, + inject, + input, + OnInit, +} from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatRippleModule } from '@angular/material/core'; @@ -8,13 +15,24 @@ import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { AsyncHandler, + Booking, + BOOKING_TYPE_COLORS, + CalendarEvent, OrganisationService, SettingsService, } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { DateCalendarComponent } from '@placeos/form-fields'; -import { isSameDay, startOfDay } from 'date-fns'; import { + IconComponent, + SettingsToggleComponent, + TranslatePipe, +} from '@placeos/components'; +import { + DateCalendarComponent, + DateRangeCalendarComponent, +} from '@placeos/form-fields'; +import { endOfDay, isSameDay, startOfDay } from 'date-fns'; +import { + isBookingForOtherUser, ScheduleOptions, ScheduleStateService, } from './schedule-state.service'; @@ -23,40 +41,8 @@ import { selector: 'schedule-sidebar', template: `
-
- - -
- @if (!is_today) { -
- -
- } @if (period === 'day') { } @if (period === 'week') { -
+
} -

- {{ 'COMMON.FILTERS' | translate }} + @if (period === 'list') { + +
+ {{ 'APP.WORKPLACE.SCHEDULE_RANGE_HINT' | translate }} +
+ } +

+ {{ 'APP.WORKPLACE.SCHEDULE_FILTERS' | translate }}

-
- @if (hasFeature('spaces')) { - - } - @if (hasFeature('desks')) { - - } - @if (hasFeature('parking') || hasFeature('parking-requests')) { - - } - @if (hasFeature('visitor-invite')) { - + + } } - @if (hasFeature('lockers')) { - - } - @if (hasFeature('group-events')) { - - } +
+
`, @@ -293,6 +163,7 @@ import { imports: [ CommonModule, MatCheckboxModule, + MatTooltipModule, TranslatePipe, IconComponent, MatRippleModule, @@ -301,6 +172,8 @@ import { MatSelectModule, MatTooltipModule, DateCalendarComponent, + DateRangeCalendarComponent, + SettingsToggleComponent, ], }) export class ScheduleSidebarComponent extends AsyncHandler implements OnInit { @@ -310,16 +183,93 @@ export class ScheduleSidebarComponent extends AsyncHandler implements OnInit { public readonly filters = this._state.filters; public readonly date = computed(() => startOfDay(this._state.date())); + public readonly end_date = computed(() => { + const end = this._state.end_date(); + return end ? endOfDay(end) : null; + }); public readonly toggleType = (t) => this._state.toggleType(t); + public readonly toggleBookingsForOthers = () => + this._state.toggleBookingsForOthers(); public readonly setDate = (d) => this._state.setDate(d); + public readonly bookings = input<(Booking | CalendarEvent)[]>([]); + public readonly view = input<'day' | 'week' | 'list'>('day'); + + public readonly colors = BOOKING_TYPE_COLORS; public readonly week_date = this._state.week_date; public readonly week_options = this._state.week_options; + public readonly feature_list = [ + { type: 'desk', feat: 'desks', icon: 'desk', name: 'RESOURCE.DESKS' }, + { + type: 'event', + feat: 'spaces', + icon: 'meeting_room', + name: 'RESOURCE.MEETINGS', + }, + { + type: 'parking', + feat: ['parking', 'parking-requests'], + icon: 'drive_eta', + name: 'RESOURCE.PARKING', + }, + { + type: 'visitor', + feat: 'visitor-invite', + icon: 'people', + name: 'RESOURCE.VISITORS', + }, + { + type: 'locker', + feat: 'lockers', + icon: 'lock', + name: 'RESOURCE.LOCKERS', + }, + { + type: 'group-event', + feat: 'group-events', + icon: 'event_available', + name: 'RESOURCE.EVENTS', + }, + ]; + + public readonly counts = computed(() => { + const mapping: Record = {}; + const bkn_list = this.bookings() || []; + for (const bkn of bkn_list) { + if (bkn instanceof CalendarEvent) { + const type = bkn.extension_data?.shared_event + ? 'group-event' + : 'event'; + mapping[type] = (mapping[type] || 0) + 1; + } else { + const type = bkn.booking_type; + mapping[type] = (mapping[type] || 0) + 1; + if (isBookingForOtherUser(bkn)) { + mapping['bookings-for-others'] = + (mapping['bookings-for-others'] || 0) + 1; + } + } + } + return mapping; + }); + public get period() { + const current_view = this.view(); + if (current_view === 'list') return 'list'; return this._state.getOptions()?.period; } + public setStartDate(date: number) { + this._state.setDate(date); + // Clear end date when start date changes + this._state.setEndDate(null); + } + + public setEndDate(date: number) { + this._state.setEndDate(date); + } + public get is_today() { return isSameDay(this._state.dateValue, Date.now()); } @@ -332,8 +282,12 @@ export class ScheduleSidebarComponent extends AsyncHandler implements OnInit { this._state.setOptions(options); } - public hasFeature(feature: string) { - return (this._settings.get('app.features') || []).includes(feature); + public hasFeature(feature: string | string[]) { + const features = this._settings.get('app.features') || []; + if (Array.isArray(feature)) { + return feature.some((f) => features.includes(f)); + } + return features.includes(feature); } public get offset_weekday() { diff --git a/apps/workplace/src/app/schedule/schedule-state.service.ts b/apps/workplace/src/app/schedule/schedule-state.service.ts index c3559a9a8e..71e7c4d7bf 100644 --- a/apps/workplace/src/app/schedule/schedule-state.service.ts +++ b/apps/workplace/src/app/schedule/schedule-state.service.ts @@ -8,10 +8,14 @@ import { signal, } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; import { + BookingFormService, loadLockerResources, + ParkingService, queryAllBookings, queryBookings, + removeBooking, setBookingCheckedIn, } from '@placeos/bookings'; import { @@ -21,16 +25,23 @@ import { CalendarEvent, currentUser, flatten, + i18n, MINUTES, + notifyError, + notifySuccess, OrganisationService, + responseErrorMessage, SettingsService, unique, } from '@placeos/common'; import { openConfirmModal } from '@placeos/components'; import { + EventFormService, newCalendarEventFromBooking, queryEvents, + removeEvent, requestSpacesForZone, + SpacesService, } from '@placeos/events'; import { getModule } from '@placeos/ts-client'; import { @@ -54,7 +65,60 @@ import { export type EventSource = 'api' | 'driver' | 'ws'; export interface ScheduleOptions { - period: 'day' | 'week' | 'month'; + period: 'day' | 'week' | 'month' | 'range'; +} + +export interface ScheduleFilters { + shown_types: string[]; + show_bookings_for_others: boolean; +} + +export function isBookingForOtherUser( + item: Booking | CalendarEvent, + current_email = currentUser()?.email, +) { + if (!(item instanceof Booking)) return false; + const current_user_email = current_email?.toLowerCase(); + const booked_by_email = item.booked_by_email?.toLowerCase(); + const user_email = item.user_email?.toLowerCase(); + return ( + !!current_user_email && + booked_by_email === current_user_email && + !!user_email && + user_email !== current_user_email + ); +} + +export function bookedForLabel(item: Booking | CalendarEvent) { + if (!(item instanceof Booking)) return ''; + return ( + `${item.user_name || ''}`.trim() || `${item.user_email || ''}`.trim() + ); +} + +/** + * End time of the item, from its start and duration. + * `date_end` is not used: for an all-day item of two or more days, the + * Booking and CalendarEvent classes set it to the end of the first day. + */ +export function itemEnd(item: Booking | CalendarEvent) { + return item.date + item.duration * 60 * 1000; +} + +/** Whether the item is in progress at some time on the day of `date` */ +export function overlapsDay(item: Booking | CalendarEvent, date: number) { + // An item with no length still counts on its start day + const item_end = Math.max(itemEnd(item), item.date + 1); + return ( + item.date <= endOfDay(date).valueOf() && + item_end > startOfDay(date).valueOf() + ); +} + +/** Key for the locally deleted list. Only bookings have recurrence instances */ +function deletedKey(item: Booking | CalendarEvent) { + const instance = item instanceof Booking ? item.instance : undefined; + return instance ? `${item.id}|${instance}` : item.id; } function deduplicateEventsByIcalUid( @@ -83,13 +147,17 @@ export class ScheduleStateService extends AsyncHandler { private _settings = inject(SettingsService); private _org = inject(OrganisationService); private _dialog = inject(MatDialog); + private _event_form = inject(EventFormService); + private _booking_form = inject(BookingFormService); + private _router = inject(Router); + private _spaces = inject(SpacesService); private _injector = inject(Injector); private _poll = signal(0); private _event_sources = signal(['api']); private _loading = signal(false); private _options = signal({ period: 'day' }); - private _filters = signal({ + private _filters = signal({ shown_types: [ 'event', 'desk', @@ -98,8 +166,10 @@ export class ScheduleStateService extends AsyncHandler { 'locker', 'group-event', ], + show_bookings_for_others: true, }); private _date = signal(Date.now()); + private _end_date = signal(null); private _deleted: string[] = []; private _ignore_cancel: string[] = []; private _network_requested = false; @@ -130,7 +200,18 @@ export class ScheduleStateService extends AsyncHandler { public readonly options = this._options.asReadonly(); public readonly filters = this._filters.asReadonly(); + /** Currently selected date (start date for range) */ public readonly date = this._date.asReadonly(); + /** + * End date for range selection (list view only). + * Null when it is not set or when it is before the selected date. This + * service is shared, so a range end can stay set after the date changes. + */ + public readonly end_date = computed(() => { + const end = this._end_date(); + if (end === null) return null; + return end >= startOfDay(this._date()).valueOf() ? end : null; + }); public readonly loading = this._loading.asReadonly(); public readonly event_sources = this._event_sources.asReadonly(); @@ -139,6 +220,7 @@ export class ScheduleStateService extends AsyncHandler { if (!this._requests_ready()) return undefined; return { date: this._date(), + end_date: this.end_date(), period: this._options().period, }; }); @@ -273,12 +355,10 @@ export class ScheduleStateService extends AsyncHandler { public readonly filtered_bookings = computed(() => this.bookings().filter((_) => { const filters = this._filters(); + if (this._deleted.includes(deletedKey(_))) return false; if ( - this._deleted.includes( - (_ as any).instance - ? `${_.id}|${(_ as any).instance}` - : _.id, - ) + this.isBookingForOtherUser(_) && + !filters?.show_bookings_for_others ) { return false; } @@ -339,7 +419,9 @@ export class ScheduleStateService extends AsyncHandler { list.filter((_) => !_.linked_event), ); this._loadBookingType('desk', this._desks); - this._loadBookingType('parking', this._parking_bookings); + this._loadBookingType('parking', this._parking_bookings, (list) => + this._resolveParkingNames(list), + ); this._loadBookingType('locker', this._locker_bookings); this._loadLockers(); this.interval('wfh_checks', () => this._checkCancel(), 60 * 1000); @@ -410,11 +492,18 @@ export class ScheduleStateService extends AsyncHandler { this._date.set(date); } - public removeItem(item) { + public setEndDate(date: number | null) { + this._end_date.set(date); + } + + public setDateRange(start: number, end: number | null) { + this._date.set(start); + this._end_date.set(end); + } + + public removeItem(item: Booking | CalendarEvent) { this._startNetwork(); - this.setAsDeleted( - item.instance ? `${item.id}|${item.instance}` : item.id, - ); + this.setAsDeleted(deletedKey(item)); this._poll.set(Date.now()); } @@ -427,7 +516,7 @@ export class ScheduleStateService extends AsyncHandler { } public setType(name: string, state: boolean) { - const filters = this._filters() || { shown_types: [] }; + const filters = this._filters(); const { shown_types } = filters; if (shown_types.includes(name) === state) return; const new_types = state @@ -436,8 +525,22 @@ export class ScheduleStateService extends AsyncHandler { this._filters.set({ ...filters, shown_types: new_types }); } + public setBookingsForOthers(state: boolean) { + const filters = this._filters(); + if (filters.show_bookings_for_others === state) return; + this._filters.set({ ...filters, show_bookings_for_others: state }); + } + + public isBookingForOtherUser(item: Booking | CalendarEvent) { + return isBookingForOtherUser(item); + } + + public toggleBookingsForOthers() { + this.setBookingsForOthers(!this._filters().show_bookings_for_others); + } + public async toggleType(name: string, clear = false) { - const filters = this._filters() || { shown_types: [] }; + const filters = this._filters(); const { shown_types } = filters; if (shown_types && (shown_types.includes(name) || clear)) { this._filters.set({ @@ -541,9 +644,9 @@ export class ScheduleStateService extends AsyncHandler { this._api_events.set([]); return; } - const { date, period } = query_params; + const { date, end_date, period } = query_params; this._loading.set(true); - const query = this._periodQuery(period, date); + const query = this._periodQuery(period, date, end_date); const list = this._settings.get('app.events.use_bookings') ? await queryAllBookings({ ...query, type: 'room' }) .then((_) => @@ -570,9 +673,14 @@ export class ScheduleStateService extends AsyncHandler { target.set([]); return; } - const { date, period } = query_params; + const { date, end_date, period } = query_params; this._loading.set(true); - const list = await this._bookingQuery(type, period, date); + const list = await this._bookingQuery( + type, + period, + date, + end_date, + ); target.set(map_list(list)); this.timeout('end_loading', () => this._loading.set(false)); }, @@ -580,6 +688,25 @@ export class ScheduleStateService extends AsyncHandler { ); } + /** + * Replace the asset name of parking bookings with the human readable + * parking space name/identifier. Without this the booking falls back to + * showing the raw `asset_id` of the space. + */ + private _resolveParkingNames(list: Booking[]): Booking[] { + const spaces = this._injector.get(ParkingService).spaces(); + return list.map((booking) => { + // Already has a proper name, nothing to resolve + if (booking.asset_name && booking.asset_name !== booking.asset_id) { + return booking; + } + const space = spaces.find((_) => _.id === booking.asset_id); + const name = space?.name || space?.identifier; + if (!name) return booking; + return new Booking({ ...booking.toJSON(), asset_name: name }); + }); + } + private _loadLockers() { effect( async () => { @@ -671,36 +798,47 @@ export class ScheduleStateService extends AsyncHandler { } } - private _periodQuery(period: 'day' | 'week' | 'month', date: number) { + private _periodQuery( + period: 'day' | 'week' | 'month' | 'range', + date: number, + end_date?: number | null, + ) { return { period_start: getUnixTime( - period === 'day' + period === 'range' || period === 'day' ? startOfDay(date) : startOfWeek(date, { weekStartsOn: this.offset_weekday, }), ), period_end: getUnixTime( - period === 'day' - ? endOfDay(date) - : endOfWeek(date, { - weekStartsOn: this.offset_weekday, - }), + period === 'range' + ? endOfDay(end_date || date) + : period === 'day' + ? endOfDay(date) + : endOfWeek(date, { + weekStartsOn: this.offset_weekday, + }), ), }; } private _bookingQuery( type: BookingType, - period: 'day' | 'week' | 'month', + period: 'day' | 'week' | 'month' | 'range', date: number, + end_date?: number | null, ) { const query = { - ...this._periodQuery(period, date), + ...this._periodQuery(period, date, end_date), type, include_checked_out: true, include_booked_by: true, include_deleted: true, + // Recurring series only expand cancelled instances when the + // `deleted` flag is also set. Without it a cancelled day of a + // multi-day parking booking is dropped from the response. + deleted: true, }; const key = JSON.stringify(query); const existing = this._booking_query_requests.get(key); @@ -807,4 +945,209 @@ export class ScheduleStateService extends AsyncHandler { } } } + + /////////////////////////////////////////////////////////////// + + public async edit(event: CalendarEvent) { + this._router.navigate(['/book', 'meeting', 'form']); + if (event.creator !== event.mailbox) { + event = + ( + await queryEvents({ + period_start: event.event_start, + period_end: event.event_end, + ical_uid: event.ical_uid, + }) + ).find((_) => _.ical_uid === event.ical_uid) || event; + } + // Load full space details for resources + const resources = event.resources?.length + ? event.resources + : event.system + ? [event.system] + : []; + if (resources.length) { + const full_resources = await Promise.all( + resources.map(async (resource) => { + // Use email or id as the lookup key + const lookup_key = resource.email || resource.id; + if (!lookup_key) return resource; + + // Try to find in cache first (by id or email) + let space = this._spaces.find(lookup_key); + if (!space) { + // If not in cache, load from API + try { + await this._spaces.loadSpace(lookup_key); + space = this._spaces.find(lookup_key); + } catch (err) { + console.warn( + `Failed to load space ${lookup_key}:`, + err, + ); + } + } + // Return full space or fallback to original resource + return space || resource; + }), + ); + // Create new event with full resources + event = new CalendarEvent({ + ...event.toJSON(), + resources: full_resources, + }); + } + setTimeout(() => this._event_form.newForm(event), 300); + } + + public editBooking(event: Booking) { + const booking_type = `${event.booking_type || ''}`.trim() || event.type; + this._router.navigate(['/book', `${event.type}`]); + this._booking_form.newForm(booking_type as BookingType, event); + if (booking_type === 'visitor') return; + setTimeout(() => { + this._booking_form.model.update((m) => ({ + ...m, + resources: [ + { + id: event.asset_id, + name: event.asset_name || event.description, + }, + ], + asset_id: event.asset_id, + })); + }, 100); + } + + public async remove(item: CalendarEvent | Booking, remove_series = false) { + const time = `${format(item.date, 'dd MMM yyyy h:mma')}`; + const resource_name = + item instanceof CalendarEvent + ? item.title || item.space?.display_name + : item.title || item.asset_name || item.asset_id; + const resp = await openConfirmModal( + { + title: i18n( + remove_series + ? 'APP.WORKPLACE.SCHEDULE_REMOVE_SERIES_TITLE' + : 'APP.WORKPLACE.SCHEDULE_REMOVE_TITLE', + { name: resource_name, time }, + ), + content: i18n( + remove_series + ? 'APP.WORKPLACE.SCHEDULE_REMOVE_SERIES_MSG' + : 'APP.WORKPLACE.SCHEDULE_REMOVE_MSG', + { name: resource_name, time }, + ), + icon: { content: 'delete' }, + }, + this._dialog, + ); + if (resp.reason !== 'done') return; + if ( + item instanceof CalendarEvent && + !item.from_bookings && + item.creator !== item.mailbox + ) { + item = + ( + await queryEvents({ + period_start: item.event_start, + period_end: item.event_end, + ical_uid: item.ical_uid, + }) + ).find( + (_) => _.ical_uid === (item as CalendarEvent).ical_uid, + ) || item; + } + resp.loading( + i18n( + remove_series + ? 'APP.WORKPLACE.SCHEDULE_REMOVE_SERIES_LOADING' + : 'APP.WORKPLACE.SCHEDULE_REMOVE_LOADING', + ), + ); + const event = item instanceof CalendarEvent ? item : undefined; + const instance = item instanceof Booking ? item.instance : undefined; + // `instance` and `start_time` select one occurrence of a booking + const options = { + calendar: this._settings.get('app.events.use_bookings') + ? null + : event?.calendar || currentUser()?.email, + system_id: event?.system?.id, + instance: remove_series ? undefined : !!instance, + start_time: instance || undefined, + }; + await (event && !event.from_bookings ? removeEvent : removeBooking)( + remove_series ? event?.recurring_event_id || item.id : item.id, + options, + ).catch(async (e) => { + notifyError( + i18n( + remove_series + ? 'APP.WORKPLACE.SCHEDULE_REMOVE_SERIES_ERROR' + : 'APP.WORKPLACE.SCHEDULE_REMOVE_ERROR', + { error: await responseErrorMessage(e) }, + ), + ); + resp.close(); + throw e; + }); + notifySuccess( + i18n( + remove_series + ? 'APP.WORKPLACE.SCHEDULE_REMOVE_SERIES_SUCCESS' + : 'APP.WORKPLACE.SCHEDULE_REMOVE_SUCCESS', + ), + ); + if (item instanceof CalendarEvent) this.removeItem(item); + else this.triggerPoll(); + this._dialog.closeAll(); + } + + public async end(item: Booking) { + const now = Date.now(); + const resource_name = item.asset_name || item.asset_id; + + // Fail if the current time is past the booking end + if (isAfter(now, item.date_end)) { + notifyError(i18n('APP.WORKPLACE.SCHEDULE_END_ALREADY_ENDED_ERROR')); + return; + } + + // Delete booking if current time is before the start + if (isBefore(now, item.date)) { + return this.remove(item); + } + + // Otherwise, check out the booking to end it now + const time = `${format(item.date, 'dd MMM yyyy h:mma')}`; + const resp = await openConfirmModal( + { + title: i18n('APP.WORKPLACE.SCHEDULE_END_TITLE'), + content: i18n('APP.WORKPLACE.SCHEDULE_END_MSG', { + name: resource_name, + time, + }), + icon: { content: 'event_busy' }, + }, + this._dialog, + ); + + if (resp.reason !== 'done') return; + resp.loading(i18n('APP.WORKPLACE.SCHEDULE_END_LOADING')); + const promise = setBookingCheckedIn(item, false).catch(async (e) => { + notifyError( + i18n('APP.WORKPLACE.SCHEDULE_END_ERROR', { + error: await responseErrorMessage(e), + }), + ); + resp.close(); + throw e; + }); + await promise; + notifySuccess(i18n('APP.WORKPLACE.SCHEDULE_END_SUCCESS')); + this.triggerPoll(); + this._dialog.closeAll(); + } } diff --git a/apps/workplace/src/app/schedule/schedule-topbar.component.ts b/apps/workplace/src/app/schedule/schedule-topbar.component.ts new file mode 100644 index 0000000000..aba435eb9a --- /dev/null +++ b/apps/workplace/src/app/schedule/schedule-topbar.component.ts @@ -0,0 +1,186 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, model } from '@angular/core'; +import { MatMenuModule } from '@angular/material/menu'; +import { RouterModule } from '@angular/router'; +import { SettingsService } from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { DateRangeCalendarComponent } from '@placeos/form-fields'; +import { addDays, isSameDay, subDays } from 'date-fns'; + +@Component({ + selector: `schedule-topbar`, + template: ` +
+
+ + @if (view() !== 'list') { + + + } @else { + + +
+ +
+ {{ + 'APP.WORKPLACE.SCHEDULE_RANGE_HINT' + | translate + }} +
+
+
+ } + @if (has_date_range()) { +
+ {{ date() | date: 'dd MMM' }} - + {{ end_date() | date: 'dd MMM yyyy' }} +
+ } @else { +
{{ date() | date: 'EEE, dd MMM' }}
+ } +
+ {{ date() | date: 'z' }} +
+
+ +
+ `, + styles: [ + ` + ::ng-deep .schedule-date-menu.mat-mdc-menu-panel { + max-width: none; + } + ::ng-deep .schedule-date-menu .mat-mdc-menu-content { + padding: 0; + overflow: hidden; + } + `, + ], + imports: [ + CommonModule, + TranslatePipe, + IconComponent, + RouterModule, + MatMenuModule, + DateRangeCalendarComponent, + ], +}) +export class ScheduleTopbarComponent { + private _settings = inject(SettingsService); + public readonly view = model<'day' | 'week' | 'list'>('list'); + public readonly date = model(Date.now()); + public readonly end_date = model(null); + + public get offset_weekday() { + return this._settings.get('app.week_start') || 0; + } + + public readonly setStartDate = (d: number) => { + this.date.set(d); + this.end_date.set(null); + }; + + public readonly has_date_range = computed(() => { + const end = this.end_date(); + const start = this.date(); + return this.view() === 'list' && end !== null && !isSameDay(start, end); + }); + + public readonly resetDate = () => { + this.date.set(Date.now()); + this.end_date.set(null); + }; + public readonly previousDate = () => + this.date.set( + subDays(this.date(), this.view() === 'week' ? 7 : 1).valueOf(), + ); + public readonly nextDate = () => + this.date.set( + addDays(this.date(), this.view() === 'week' ? 7 : 1).valueOf(), + ); +} diff --git a/apps/workplace/src/app/schedule/schedule-week-view.component.ts b/apps/workplace/src/app/schedule/schedule-week-view.component.ts new file mode 100644 index 0000000000..b31f1b614a --- /dev/null +++ b/apps/workplace/src/app/schedule/schedule-week-view.component.ts @@ -0,0 +1,402 @@ +import { ComponentType } from '@angular/cdk/portal'; +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + effect, + ElementRef, + inject, + input, + viewChild, +} from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { + BookingDetailsModalComponent, + bookingLocationString, + isInWaitlistWeek, + visitorDisplayNameFor, +} from '@placeos/bookings'; +import { + Booking, + BOOKING_TYPE_COLORS, + CalendarEvent, + OrganisationService, +} from '@placeos/common'; +import { IconComponent } from '@placeos/components'; +import { + EventDetailsModalComponent, + GroupEventDetailsModalComponent, +} from '@placeos/events'; +import { UserPipe } from '@placeos/users'; +import { + addDays, + format, + isBefore, + isSameDay, + startOfDay, + startOfWeek, +} from 'date-fns'; +import { + bookedForLabel, + isBookingForOtherUser, + overlapsDay, + ScheduleStateService, +} from './schedule-state.service'; + +interface Weekday { + id: string; + date: number; + is_past: boolean; + is_today: boolean; +} + +@Component({ + selector: `schedule-week-view`, + template: ` +
+
+
+ @for (day of weekdays(); track day.id) { +
+
+ {{ day.date | date: 'EEE, dd' }} +
+
+ {{ bookings_by_date()[day.id]?.length || 0 }} +
+
+ } + @for (day of weekdays(); track day.id) { +
+ @for ( + bkn of bookings_by_date()[day.id] || []; + track bkn.id + ) { + + } + @if (!bookings_by_date()[day.id]?.length) { +
+ event_busy +
No bookings
+
+ } +
+ } +
+
+
+ `, + styles: [``], + imports: [ + CommonModule, + IconComponent, + MatRippleModule, + MatTooltipModule, + UserPipe, + ], +}) +export class ScheduleWeekViewComponent { + private _dialog = inject(MatDialog); + private _state = inject(ScheduleStateService); + private _org = inject(OrganisationService); + public readonly date = input(Date.now()); + public readonly bookings = input<(Booking | CalendarEvent)[]>([]); + public readonly loading = input(false); + public readonly colors = BOOKING_TYPE_COLORS; + + private readonly _scroll_container = + viewChild>('scrollContainer'); + + constructor() { + effect(() => { + this.weekdays(); // Track weekdays changes + this._scrollToCurrentDay(); + }); + } + + private _scrollToCurrentDay() { + const container = this._scroll_container()?.nativeElement; + if (!container) return; + + // Use setTimeout to ensure DOM is fully rendered + setTimeout(() => { + const today_column = container.querySelector( + '[data-is-today="true"]', + ) as HTMLElement; + if (!today_column) return; + + // Get positions relative to viewport + const container_rect = container.getBoundingClientRect(); + const column_rect = today_column.getBoundingClientRect(); + + // Calculate the current scroll offset + const current_scroll = container.scrollLeft; + + // Calculate where the column currently is relative to container + const column_relative_to_container = + column_rect.left - container_rect.left + current_scroll; + + // Calculate scroll position to center the column + const container_center = container.clientWidth / 2; + const column_center = column_rect.width / 2; + const scroll_position = + column_relative_to_container - container_center + column_center; + + container.scrollTo({ + left: scroll_position, + behavior: 'smooth', + }); + }, 0); + } + + public readonly weekdays = computed(() => { + const days: Weekday[] = []; + const week_start = startOfWeek(this.date(), { + weekStartsOn: this._state.offset_weekday, + }); + for (let i = 0; i < 7; i++) { + const date = addDays(week_start, i); + days.push({ + id: format(date, 'yyyy-MM-dd'), + date: date.valueOf(), + is_past: isBefore(date, startOfDay(Date.now())), + is_today: isSameDay(date, Date.now()), + }); + } + return days; + }); + public readonly bookings_by_date = computed(() => { + // A multi-day booking is listed on each day that it covers + const bookings = this.bookings() || []; + const date_map: Record = {}; + for (const day of this.weekdays()) { + date_map[day.id] = bookings.filter((bkn) => + overlapsDay(bkn, day.date), + ); + } + return date_map; + }); + + public type(booking: Booking | CalendarEvent) { + if (booking instanceof Booking) return booking.booking_type; + return booking.extension_data?.shared_event ? 'group-event' : 'event'; + } + + public bookingStatus( + booking: Booking | CalendarEvent, + ): 'approved' | 'tentative' | 'declined' | 'waitlisted' | null { + const status = booking.status; + if ( + status === 'tentative' && + booking instanceof Booking && + booking.booking_type === 'parking' && + isInWaitlistWeek(booking.date, this._org.building?.timezone) + ) { + return 'waitlisted'; + } + return status === 'approved' || + status === 'tentative' || + status === 'declined' + ? status + : null; + } + + public statusLabel( + status: 'approved' | 'tentative' | 'declined' | 'waitlisted', + ) { + return status.charAt(0).toUpperCase() + status.slice(1); + } + + public statusColor( + status: 'approved' | 'tentative' | 'declined' | 'waitlisted', + ) { + if (status === 'approved') return 'var(--success)'; + if (status === 'waitlisted') return 'var(--info)'; + if (status === 'tentative') return 'var(--warn)'; + return 'var(--error)'; + } + + public visitorName(booking: Booking | CalendarEvent): string { + // Only visitor bookings have a visitor name; for other types this + // would fall back to the raw `asset_id` (e.g. unallocated parking). + if (booking instanceof Booking && booking.booking_type === 'visitor') { + return visitorDisplayNameFor(booking); + } + return ''; + } + + public location(booking: Booking | CalendarEvent): string { + return bookingLocationString(booking, this._org); + } + + public backgroundColor(booking: Booking | CalendarEvent) { + const color = this.colors[this.type(booking)][0]; + return this.isBookingForOtherUser(booking) ? `${color}80` : color; + } + + public isBookingForOtherUser = isBookingForOtherUser; + public bookedForLabel = bookedForLabel; + + public viewBooking(bkn: CalendarEvent | Booking) { + this._dialog.closeAll(); + if (bkn instanceof CalendarEvent) { + if (bkn.extension_data?.shared_event) { + this._dialog.open(GroupEventDetailsModalComponent, { + data: { + event: bkn, + edit_fn: (i) => this._state.edit(i), + remove_fn: (i, t) => this._state.remove(i, t), + concierge: false, + }, + }); + } else { + this._dialog.open(EventDetailsModalComponent, { + data: { + event: bkn, + edit_fn: (i) => this._state.edit(i), + remove_fn: (i, t) => this._state.remove(i, t), + }, + }); + } + } else { + const view_component: ComponentType = + bkn.booking_type === 'group-event' + ? GroupEventDetailsModalComponent + : BookingDetailsModalComponent; + const data = { + booking: + bkn.booking_type === 'group-event' + ? { booking: bkn, concierge: false } + : bkn, + edit_fn: (i) => this._state.editBooking(i), + remove_fn: (i, t) => this._state.remove(i, t), + end_fn: (i) => this._state.end(i), + refresh_fn: () => this._state.triggerPoll(), + }; + this._dialog.open(view_component, { data }); + } + } +} diff --git a/apps/workplace/src/app/schedule/schedule.component.ts b/apps/workplace/src/app/schedule/schedule.component.ts index 2c757b82de..14ef3fe956 100644 --- a/apps/workplace/src/app/schedule/schedule.component.ts +++ b/apps/workplace/src/app/schedule/schedule.component.ts @@ -1,46 +1,34 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, inject, OnInit } from '@angular/core'; +import { + Component, + computed, + inject, + OnDestroy, + OnInit, + signal, +} from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; -import { MatDialog } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSelectModule } from '@angular/material/select'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Router } from '@angular/router'; -import { - BookingCardComponent, - BookingFormService, - removeBooking, - setBookingCheckedIn, -} from '@placeos/bookings'; -import { - AsyncHandler, - Booking, - CalendarEvent, - currentUser, - i18n, - notifyError, - notifySuccess, - SettingsService, -} from '@placeos/common'; -import { openConfirmModal, TranslatePipe } from '@placeos/components'; -import { - EventCardComponent, - EventFormService, - queryEvents, - removeEvent, -} from '@placeos/events'; -import { format, isSameDay, parse } from 'date-fns'; +import { ActivatedRoute } from '@angular/router'; +import { AsyncHandler, Booking } from '@placeos/common'; import { FooterMenuComponent } from '../components/footer-menu.component'; import { TopbarComponent } from '../components/topbar.component'; +import { VirtualConciergeButtonComponent } from '../components/virtual-concierge-button.component'; +import { ScheduleDayViewComponent } from './schedule-day-view.component'; import { ScheduleFiltersComponent } from './schedule-filters.component'; -import { ScheduleMobileCalendarComponent } from './schedule-mobile-calendar.component'; +import { ScheduleListViewComponent } from './schedule-list-view.component'; import { ScheduleSidebarComponent } from './schedule-sidebar.component'; import { ScheduleOptions, ScheduleStateService, } from './schedule-state.service'; +import { ScheduleTopbarComponent } from './schedule-topbar.component'; +import { ScheduleWeekViewComponent } from './schedule-week-view.component'; + +type ScheduleView = 'day' | 'week' | 'list'; @Component({ selector: 'app-schedule', @@ -48,115 +36,49 @@ import { @if (!hide_nav) { } +
-
-
- - -
- @if (period === 'day') { - - } - @if (period === 'week') { -
- - - @for (option of week_options(); track option) { - - {{ option.name }} - @if (option.this_week) { - (C) - } - - } - - -
- } -
-
- - @if (booking_dates().length) { - @for ( - date_block of booking_dates(); - track date_block.date - ) { -

- {{ date_block.date | date: 'EEE dd LLL yyyy' }} - @if (date_block.is_today) { - - ({{ 'COMMON.TODAY' | translate }}) - - } -

- @for (item of date_block.bookings; track item.id) { - @if (isEvent(item)) { - - } @else { - - } - } - } +
+ + @if (view() === 'day') { + + } @else if (view() === 'week') { + } @else { -
- -

- {{ 'APP.WORKPLACE.SCHEDULE_EMPTY' | translate }} - {{ date() | date: 'EEEE, dd LLL yyyy' }} -

+
+
}
@@ -166,6 +88,7 @@ import { mode="indeterminate" > } +
@if (!hide_nav) { @@ -188,240 +111,79 @@ import { MatProgressBarModule, MatFormFieldModule, MatSelectModule, - MatTooltipModule, - TranslatePipe, FooterMenuComponent, - BookingCardComponent, - EventCardComponent, + VirtualConciergeButtonComponent, ScheduleFiltersComponent, - ScheduleMobileCalendarComponent, ScheduleSidebarComponent, TopbarComponent, + ScheduleTopbarComponent, + ScheduleListViewComponent, + ScheduleWeekViewComponent, + ScheduleDayViewComponent, ], }) -export class ScheduleComponent extends AsyncHandler implements OnInit { +export class ScheduleComponent + extends AsyncHandler + implements OnInit, OnDestroy +{ private _state = inject(ScheduleStateService); - private _event_form = inject(EventFormService); - private _booking_form = inject(BookingFormService); - private _router = inject(Router); - private _dialog = inject(MatDialog); - private _settings = inject(SettingsService); + private _route = inject(ActivatedRoute); - public readonly booking_dates = computed(() => { - const bookings = this._state.loading() - ? [] - : [...this._state.filtered_bookings()]; - const sorted = bookings.sort((a, b) => a.date - b.date); - const dates = new Set(); - for (const booking of sorted) { - const date = format(booking.date, 'yyyy-MM-dd'); - if (!dates.has(date)) dates.add(date); - } - const list = []; - for (const date of dates) { - const day = parse(date, 'yyyy-MM-dd', 0); - list.push({ - id: date, - date: day.valueOf(), - bookings: sorted.filter((booking) => - isSameDay(booking.date, day), - ), - is_today: isSameDay(day, Date.now()), - }); - } - return list; - }); - public readonly date = this._state.date; + public readonly b_list = this._state.bookings; + public readonly bookings = this._state.filtered_bookings; + public readonly calendar_bookings = computed(() => + this.bookings().filter( + (booking) => + !(booking instanceof Booking) || booking.status !== 'cancelled', + ), + ); public readonly loading = this._state.loading; - public readonly setDate = (d) => this._state.setDate(d); - public readonly week_date = this._state.week_date; - public readonly week_options = this._state.week_options; + public readonly view = signal('day'); + public readonly date = this._state.date; + public readonly end_date = this._state.end_date; + public readonly setDate = (d: number) => this._state.setDate(d); + public readonly setEndDate = (d: number | null) => + this._state.setEndDate(d); public get period() { return this._state.getOptions().period; } - public isEvent(item: unknown) { - return item instanceof CalendarEvent; - } - public get hide_nav() { return localStorage.getItem('PlaceOS.hide_nav') === 'true'; } - public readonly edit_fn = (i) => this.edit(i); - public readonly edit_booking_fn = (i) => this.editBooking(i); - public readonly remove_fn = (i, t?) => this.remove(i, t); - public readonly end_fn = (i) => this.end(i); - public readonly refresh_fn = () => this._state.triggerPoll(); - public ngOnInit() { + this.setOptions({ period: 'day' }); this._state.triggerPoll(); this.subscription('poll', this._state.startPolling()); + this.subscription( + 'route.query', + this._route.queryParamMap.subscribe((params) => { + if (params.has('view')) { + this.view.set(params.get('view') as ScheduleView); + if (this.view() === 'week') { + this.setOptions({ period: 'week' }); + } else if (this.view() === 'list') { + this.setOptions({ period: 'range' }); + } else { + this.setOptions({ period: 'day' }); + } + } + }), + ); } - public trackByFn(index: number, item: { id: string }) { - return item?.id; + public ngOnDestroy() { + super.ngOnDestroy(); + // The state service is shared with the landing page. Do not leave + // the list view range or the week period active for it. + this._state.setOptions({ period: 'day' }); + this._state.setEndDate(null); } public setOptions(options: ScheduleOptions) { this._state.setOptions(options); } - - public async edit(event: CalendarEvent) { - this._router.navigate(['/book', 'meeting', 'form']); - if (event.creator !== event.mailbox) { - event = - ( - await queryEvents({ - period_start: event.event_start, - period_end: event.event_end, - ical_uid: event.ical_uid, - }) - ).find((_) => _.ical_uid === event.ical_uid) || event; - } - setTimeout(() => this._event_form.newForm(event), 300); - } - - public editBooking(event: Booking) { - this._router.navigate(['/book', `${event.type}`]); - this._booking_form.newForm(event.booking_type, event); - if (event.booking_type === 'visitor') return; - setTimeout(() => { - this._booking_form.model.update((m) => ({ - ...m, - resources: [ - { - id: event.asset_id, - name: event.asset_name || event.description, - }, - ], - asset_id: event.asset_id, - })); - }, 100); - } - - public async remove(item: CalendarEvent | Booking, remove_series = false) { - const time = `${format(item.date, 'dd MMM yyyy h:mma')}`; - const resource_name = - item instanceof CalendarEvent - ? item.space?.display_name - : item.asset_name || item.asset_id; - const resp = await openConfirmModal( - { - title: i18n( - remove_series - ? 'APP.WORKPLACE.SCHEDULE_REMOVE_SERIES_TITLE' - : 'APP.WORKPLACE.SCHEDULE_REMOVE_TITLE', - { name: resource_name, time }, - ), - content: i18n( - remove_series - ? 'APP.WORKPLACE.SCHEDULE_REMOVE_SERIES_MSG' - : 'APP.WORKPLACE.SCHEDULE_REMOVE_MSG', - { name: resource_name, time }, - ), - icon: { content: 'delete' }, - }, - this._dialog, - ); - if (resp.reason !== 'done') return; - if ( - item instanceof CalendarEvent && - !item.from_bookings && - item.creator !== item.mailbox - ) { - item = - ( - await queryEvents({ - period_start: item.event_start, - period_end: item.event_end, - ical_uid: item.ical_uid, - }) - ).find( - (_) => _.ical_uid === (item as CalendarEvent).ical_uid, - ) || item; - } - resp.loading( - i18n( - remove_series - ? 'APP.WORKPLACE.SCHEDULE_REMOVE_SERIES_LOADING' - : 'APP.WORKPLACE.SCHEDULE_REMOVE_LOADING', - ), - ); - const remove_result = ( - item instanceof CalendarEvent && !item.from_bookings - ? removeEvent - : removeBooking - )( - remove_series - ? (item as any).recurring_event_id || item.id - : item.id, - { - calendar: this._settings.get('app.events.use_bookings') - ? null - : (item as CalendarEvent).calendar || currentUser()?.email, - system_id: (item as CalendarEvent).system?.id, - instance: remove_series ? undefined : !!(item as any).instance, - start_time: (item as any).instance - ? (item as any).instance - : undefined, - }, - ) as any; - await ( - remove_result?.then instanceof Function - ? remove_result - : remove_result.toPromise() - ).catch((e) => { - notifyError( - i18n( - remove_series - ? 'APP.WORKPLACE.SCHEDULE_REMOVE_SERIES_ERROR' - : 'APP.WORKPLACE.SCHEDULE_REMOVE_ERROR', - { error: e }, - ), - ); - resp.close(); - throw e; - }); - notifySuccess( - i18n( - remove_series - ? 'APP.WORKPLACE.SCHEDULE_REMOVE_SERIES_SUCCESS' - : 'APP.WORKPLACE.SCHEDULE_REMOVE_SUCCESS', - ), - ); - if (item instanceof CalendarEvent) this._state.removeItem(item); - else this._state.triggerPoll(); - this._dialog.closeAll(); - } - - public async end(item: Booking) { - const time = `${format(item.date, 'dd MMM yyyy h:mma')}`; - const resource_name = item.asset_name || item.asset_id; - const resp = await openConfirmModal( - { - title: i18n('APP.WORKPLACE.SCHEDULE_END_TITLE'), - content: i18n('APP.WORKPLACE.SCHEDULE_END_MSG', { - name: resource_name, - time, - }), - icon: { content: 'event_busy' }, - }, - this._dialog, - ); - - if (resp.reason !== 'done') return; - resp.loading(i18n('APP.WORKPLACE.SCHEDULE_END_LOADING')); - const promise = setBookingCheckedIn(item, false).catch((e) => { - notifyError(i18n('APP.WORKPLACE.SCHEDULE_END_ERROR', { error: e })); - resp.close(); - throw e; - }); - await promise; - notifySuccess(i18n('APP.WORKPLACE.SCHEDULE_END_SUCCESS')); - this._state.triggerPoll(); - this._dialog.closeAll(); - } } diff --git a/apps/workplace/src/app/team-schedule/common.ts b/apps/workplace/src/app/team-schedule/common.ts new file mode 100644 index 0000000000..aca4998dda --- /dev/null +++ b/apps/workplace/src/app/team-schedule/common.ts @@ -0,0 +1,76 @@ +import { StaffUser, User } from '@placeos/common'; + +export type LocationStatus = + | 'unspecified' + | 'wfh' + | 'office' + | 'onsite' + | 'on_leave' + | 'off_sick'; + +export interface DeskBooking { + building_name: string; + desk_code: string; + desk_id?: string; + level_id?: string; + date?: number; + duration?: number; +} + +export interface DayStatus { + date: number; + status: LocationStatus; + booking?: DeskBooking; +} + +export interface TeamMember { + user: StaffUser | User; + is_favorite: boolean; + is_my_team: boolean; + department?: string; + current_building?: string; + current_desk?: string; + statuses: DayStatus[]; +} + +export const USER_LOCATIONS: Record< + LocationStatus, + { name: string; icon: string; fg_color: string; bg_color: string } +> = { + unspecified: { + name: 'COMMON.LOCATION_UNSPECIFIED', + icon: 'help_outline', + fg_color: 'rgb(107, 114, 128)', + bg_color: 'rgb(229, 231, 235)', + }, + wfh: { + name: 'COMMON.LOCATION_WFH', + icon: 'home', + fg_color: 'rgb(37, 99, 235)', + bg_color: 'rgb(219, 234, 254)', + }, + office: { + name: 'COMMON.LOCATION_OFFICE', + icon: 'business', + fg_color: 'rgb(5, 150, 105)', + bg_color: 'rgb(209, 250, 229)', + }, + onsite: { + name: 'COMMON.LOCATION_ONSITE', + icon: 'location_on', + fg_color: 'rgb(217, 119, 6)', + bg_color: 'rgb(254, 243, 199)', + }, + on_leave: { + name: 'COMMON.LOCATION_LEAVE', + icon: 'event_busy', + fg_color: 'rgb(124, 58, 237)', + bg_color: 'rgb(237, 233, 254)', + }, + off_sick: { + name: 'COMMON.LOCATION_SICK', + icon: 'local_hospital', + fg_color: 'rgb(220, 38, 38)', + bg_color: 'rgb(254, 226, 226)', + }, +}; diff --git a/apps/workplace/src/app/team-schedule/team-quick-actions.component.ts b/apps/workplace/src/app/team-schedule/team-quick-actions.component.ts new file mode 100644 index 0000000000..d0005accfd --- /dev/null +++ b/apps/workplace/src/app/team-schedule/team-quick-actions.component.ts @@ -0,0 +1,138 @@ +import { Component, inject } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { currentUser, settingSignal, User } from '@placeos/common'; +import { IconComponent } from '@placeos/components'; +import { BookingFormService } from '@placeos/bookings'; +import { TeamScheduleService } from './team-schedule.service'; + +@Component({ + selector: 'team-quick-actions', + template: ` +
+
+
+
+
Quick Action
+
+
+ auto_awesome +
Smart Selection
+
+
+
+

Group Desk Bookings

+

+ Book desks together with your team or selected colleagues +

+
+
+ + +
+
+ `, + styles: [ + ` + .bg-grad { + background: linear-gradient( + 105deg, + var(--brand-100) 0%, + var(--brand-200) 100% + ); + } + `, + ], + imports: [IconComponent], +}) +export class TeamQuickActionsComponent { + private _dialog = inject(MatDialog); + private _router = inject(Router); + private _booking_form = inject(BookingFormService); + private _service = inject(TeamScheduleService); + + public readonly features = settingSignal('features', []); + + // Expose data + public readonly select_mode = this._service.select_mode; + public readonly selected_count = this._service.selected_count; + + public toggleSelectMode() { + if (this._service.select_mode()) { + this._service.clearSelection(); + } else { + this._service.toggleSelectMode(); + } + } + + public bookForGroup() { + let members: User[]; + + if (this._service.select_mode() && this._service.selected_count() > 0) { + // Use selected members + members = this._service + .selected_members() + .map((m) => m.user as User); + } else { + // Use team members + members = this._service.getTeamMembers().map((m) => m.user as User); + } + + // Add current user if not already included + const current = currentUser(); + if (!members.find((m) => m.email === current.email)) { + members = [current, ...members]; + } + + // Clear selection mode + this._service.clearSelection(); + + // Set up group booking + this._booking_form.setOptions({ + type: 'desk', + group: true, + members, + }); + + // Navigate to desk booking flow + this._router.navigate(['/book', 'desks']); + } +} diff --git a/apps/workplace/src/app/team-schedule/team-schedule-filters.component.ts b/apps/workplace/src/app/team-schedule/team-schedule-filters.component.ts new file mode 100644 index 0000000000..b54b7da452 --- /dev/null +++ b/apps/workplace/src/app/team-schedule/team-schedule-filters.component.ts @@ -0,0 +1,228 @@ +import { Component, computed, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { AsyncHandler } from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { LocationStatus, USER_LOCATIONS } from './common'; +import { + TeamScheduleFilters, + TeamScheduleService, + ViewFilter, +} from './team-schedule.service'; + +@Component({ + selector: 'team-schedule-filters', + template: ` +
+
+ +
+ + search + + + + + +
+
+
+ + + {{ + 'COMMON.ALL_DEPARTMENTS' | translate + }} + @for (dept of departments(); track dept) { + {{ dept }} + } + + + + + {{ + 'COMMON.ALL_OFFICES' | translate + }} + @for (office of offices(); track office) { + {{ + office + }} + } + + + + + {{ + 'COMMON.ALL_STATUSES' | translate + }} + @for (status of statuses(); track status) { + {{ + user_locations[status].name | translate + }} + } + + +
+
+ `, + styles: [ + ` + a { + border-width: 1px; + } + `, + ], + imports: [ + TranslatePipe, + RouterModule, + IconComponent, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + FormsModule, + ], +}) +export class TeamScheduleFiltersComponent extends AsyncHandler { + private _route = inject(ActivatedRoute); + private _service = inject(TeamScheduleService); + + public readonly user_locations = USER_LOCATIONS; + + // Expose filter values + public readonly filters = this._service.filters; + public readonly departments = this._service.departments; + public readonly offices = this._service.offices; + public readonly statuses = this._service.statuses; + + // Actions + public setFilters(filters: Partial) { + this._service.setFilters(filters); + } + + public previousWeek() { + this._service.previousWeek(); + } + + public nextWeek() { + this._service.nextWeek(); + } + + public resetToToday() { + this._service.setFilters({ date: Date.now(), day_index: -1 }); + } + + public ngOnInit() { + this.subscription( + 'route.query', + this._route.queryParamMap.subscribe((params) => { + if (params.has('view')) { + this._service.setFilters({ + view: params.get('view') as ViewFilter, + }); + } + }), + ); + } +} diff --git a/apps/workplace/src/app/team-schedule/team-schedule-list.component.ts b/apps/workplace/src/app/team-schedule/team-schedule-list.component.ts new file mode 100644 index 0000000000..5a5e6b2c41 --- /dev/null +++ b/apps/workplace/src/app/team-schedule/team-schedule-list.component.ts @@ -0,0 +1,315 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { + IconComponent, + TranslatePipe, + UserAvatarComponent, +} from '@placeos/components'; +import { AutoAssignedDeskModalComponent } from '../book/desk-flow/auto-assigned-desk-modal.component'; +import { AddColleaguesModalComponent } from '../landing-new/add-colleagues-modal.component'; +import { DayStatus, LocationStatus, TeamMember } from './common'; +import { TeamScheduleService } from './team-schedule.service'; + +@Component({ + selector: 'team-schedule-list', + template: ` + +
+ @for (day of week_days(); track day.date; let i = $index) { + + } +
+ + +
+ @for (member of filtered_members(); track member.user.id) { +
+ + @if (select_mode()) { + + } @else { + + } + + + + + +
+
+ + {{ member.user.name }} + + @if (member.is_my_team) { + + {{ 'COMMON.MY_TEAM' | translate }} + + } +
+
+ {{ member.department }} +
+
+ + + @if (getMemberStatus(member); as status) { + @if (status.status === 'office' && status.booking) { + + } @else { + + } + } +
+ } @empty { + +
+ @if (loading()) { +
+

+ {{ 'COMMON.LOADING' | translate }}... +

+ } @else if (total_members() === 0) { + group_off +

+ {{ + 'APP.WORKPLACE.TEAM_SCHEDULE_NO_MEMBERS' + | translate + }} +

+

+ {{ + 'APP.WORKPLACE.TEAM_SCHEDULE_NO_MEMBERS_HINT' + | translate + }} +

+ } @else { + filter_list_off +

+ {{ + 'APP.WORKPLACE.TEAM_SCHEDULE_NO_FILTER_RESULTS' + | translate + }} +

+

+ {{ + 'APP.WORKPLACE.TEAM_SCHEDULE_NO_FILTER_RESULTS_HINT' + | translate + }} +

+ } +
+ } + + +
+ +
+
+ `, + styles: [``], + imports: [ + CommonModule, + MatTooltipModule, + TranslatePipe, + IconComponent, + UserAvatarComponent, + ], +}) +export class TeamScheduleListComponent { + private _dialog = inject(MatDialog); + private _service = inject(TeamScheduleService); + + // Expose data + public readonly week_days = this._service.week_days; + public readonly active_day_index = this._service.active_day_index; + public readonly filtered_members = this._service.filtered_members; + public readonly total_members = this._service.total_members; + public readonly select_mode = this._service.select_mode; + public readonly loading = this._service.loading; + + // Actions + public setDayIndex(index: number) { + this._service.setFilters({ day_index: index }); + } + + public toggleMemberSelection(member: TeamMember) { + this._service.toggleMemberSelection(member); + } + + public isMemberSelected(member: TeamMember): boolean { + return this._service.isMemberSelected(member); + } + + public toggleFavorite(member: TeamMember) { + this._service.toggleFavorite(member); + } + + public getMemberStatus(member: TeamMember): DayStatus | undefined { + return this._service.getMemberStatus(member); + } + + public getLocationStyle(status: LocationStatus) { + return this._service.getLocationStyle(status); + } + + public bookNearby(member: TeamMember) { + const day_index = this._service.active_day_index(); + const day = this._service.week_days()[day_index]; + const status: DayStatus | undefined = member.statuses[day_index]; + const booking = status?.booking; + + const dialog_ref = this._dialog.open(AutoAssignedDeskModalComponent, { + maxWidth: '100vw', + maxHeight: '100vh', + panelClass: 'auto-assigned-desk-modal', + }); + dialog_ref.componentInstance.show_close.set(true); + + // Set date from the selected day + dialog_ref.componentInstance.date.set(day.date); + + // If the team member has a booking, use their desk and booking details + if (booking?.desk_id) { + dialog_ref.componentInstance.nearby_desk_id.set(booking.desk_id); + if (booking.level_id) { + dialog_ref.componentInstance.level_id.set(booking.level_id); + } + if (booking.duration) { + dialog_ref.componentInstance.duration.set(booking.duration); + } + } + } + + public openAddColleaguesModal() { + const dialog_ref = this._dialog.open< + AddColleaguesModalComponent, + unknown, + boolean + >(AddColleaguesModalComponent, { + maxWidth: '100vw', + maxHeight: '100vh', + panelClass: 'panel', + data: {}, + }); + // The service loads the colleagues one time. Load them again when + // the modal saved new colleagues. + dialog_ref.afterClosed().subscribe((added) => { + if (added) this._service.refresh(); + }); + } +} diff --git a/apps/workplace/src/app/team-schedule/team-schedule-table.component.ts b/apps/workplace/src/app/team-schedule/team-schedule-table.component.ts new file mode 100644 index 0000000000..0d65250a92 --- /dev/null +++ b/apps/workplace/src/app/team-schedule/team-schedule-table.component.ts @@ -0,0 +1,408 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { + IconComponent, + TranslatePipe, + UserAvatarComponent, +} from '@placeos/components'; +import { AutoAssignedDeskModalComponent } from '../book/desk-flow/auto-assigned-desk-modal.component'; +import { AddColleaguesModalComponent } from '../landing-new/add-colleagues-modal.component'; +import { + DayStatus, + LocationStatus, + TeamMember, + USER_LOCATIONS, +} from './common'; +import { TeamScheduleService } from './team-schedule.service'; + +@Component({ + selector: 'team-schedule-table', + template: ` + +
+
+ @for (location of user_locations; track location.name) { +
+
+ {{ location.icon }} +
+
+ {{ location.name | translate }} +
+
+ } +
+
+ info +

+ {{ 'APP.WORKPLACE.TEAM_SCHEDULE_BOOK_HINT' | translate }} +

+ {{ booked_count() }}/{{ total_members() }} +
+
+ + +
+ +
+
+ {{ + 'APP.WORKPLACE.TEAM_SCHEDULE_EMPLOYEE' | translate + }} + {{ filtered_members().length }}/{{ + total_members() + }} +
+ @for (day of week_days(); track day.date) { +
+
+
+ {{ day.date | date: 'EEE' }} +
+ @if (day.is_today) { +
+ {{ 'COMMON.TODAY' | translate }} +
+ } +
+
+
+ {{ day.date | date: 'd' }} +
+
+ {{ day.date | date: 'MMM' }} +
+
+
+ } +
+ + + @for (member of filtered_members(); track member.user.id) { +
+ +
+ @if (select_mode()) { + + } @else { + + } + +
+
+ {{ member.user.name }} + @if (member.is_my_team) { + {{ + 'COMMON.MY_TEAM' | translate + }} + } +
+
+ @if (member.department) { + {{ member.department }} + } + @if (member.current_building) { + • + location_on + {{ member.current_building }} + } + @if (member.current_desk) { + • + {{ member.current_desk }} + } +
+
+
+ + + @for (day of week_days(); track day.date; let i = $index) { +
+ @if (member.statuses[i]; as status) { + @if ( + status.status === 'office' && status.booking + ) { + + } @else { + + } + } +
+ } +
+ } @empty { + +
+ @if (loading()) { + +

+ {{ 'COMMON.LOADING' | translate }}... +

+ } @else if (total_members() === 0) { + group_off +

+ {{ + 'APP.WORKPLACE.TEAM_SCHEDULE_NO_MEMBERS' + | translate + }} +

+

+ {{ + 'APP.WORKPLACE.TEAM_SCHEDULE_NO_MEMBERS_HINT' + | translate + }} +

+ } @else { + filter_list_off +

+ {{ + 'APP.WORKPLACE.TEAM_SCHEDULE_NO_FILTER_RESULTS' + | translate + }} +

+

+ {{ + 'APP.WORKPLACE.TEAM_SCHEDULE_NO_FILTER_RESULTS_HINT' + | translate + }} +

+ } +
+ } +
+ + +
+ +
+ `, + styles: [ + ` + .schedule-grid { + display: grid; + grid-template-columns: + minmax(18rem, 20rem) + repeat(5, minmax(7rem, 1fr)); + } + `, + ], + imports: [ + CommonModule, + MatTooltipModule, + TranslatePipe, + IconComponent, + UserAvatarComponent, + MatProgressSpinnerModule, + ], +}) +export class TeamScheduleTableComponent { + private _dialog = inject(MatDialog); + private _service = inject(TeamScheduleService); + + public readonly user_locations = Object.keys(USER_LOCATIONS).map((key) => ({ + ...USER_LOCATIONS[key as LocationStatus], + key, + })); + + // Expose data + public readonly week_days = this._service.week_days; + public readonly filtered_members = this._service.filtered_members; + public readonly total_members = this._service.total_members; + public readonly booked_count = this._service.booked_count; + public readonly select_mode = this._service.select_mode; + public readonly loading = this._service.loading; + + // Actions + public toggleMemberSelection(member: TeamMember) { + this._service.toggleMemberSelection(member); + } + + public isMemberSelected(member: TeamMember): boolean { + return this._service.isMemberSelected(member); + } + + public toggleFavorite(member: TeamMember) { + this._service.toggleFavorite(member); + } + + public getLocationStyle(status: LocationStatus) { + return this._service.getLocationStyle(status); + } + + public bookNearby(member: TeamMember, day: { date: number }) { + const day_index = this._service + .week_days() + .findIndex((d) => d.date === day.date); + const status: DayStatus | undefined = member.statuses[day_index]; + const booking = status?.booking; + + const dialog_ref = this._dialog.open(AutoAssignedDeskModalComponent, { + maxWidth: '100vw', + maxHeight: '100vh', + panelClass: 'auto-assigned-desk-modal', + }); + dialog_ref.componentInstance.show_close.set(true); + + // Set date from the day clicked + dialog_ref.componentInstance.date.set(day.date); + + // If the team member has a booking, use their desk and booking details + if (booking?.desk_id) { + dialog_ref.componentInstance.nearby_desk_id.set(booking.desk_id); + if (booking.level_id) { + dialog_ref.componentInstance.level_id.set(booking.level_id); + } + if (booking.duration) { + dialog_ref.componentInstance.duration.set(booking.duration); + } + } + } + + public openAddColleaguesModal() { + const dialog_ref = this._dialog.open< + AddColleaguesModalComponent, + unknown, + boolean + >(AddColleaguesModalComponent, { + maxWidth: '100vw', + maxHeight: '100vh', + panelClass: 'panel', + data: {}, + }); + // The service loads the colleagues one time. Load them again when + // the modal saved new colleagues. + dialog_ref.afterClosed().subscribe((added) => { + if (added) this._service.refresh(); + }); + } +} diff --git a/apps/workplace/src/app/team-schedule/team-schedule.component.ts b/apps/workplace/src/app/team-schedule/team-schedule.component.ts new file mode 100644 index 0000000000..f093037e7f --- /dev/null +++ b/apps/workplace/src/app/team-schedule/team-schedule.component.ts @@ -0,0 +1,51 @@ +import { Component } from '@angular/core'; +import { FooterMenuComponent } from '../components/footer-menu.component'; +import { TopbarComponent } from '../components/topbar.component'; +import { VirtualConciergeButtonComponent } from '../components/virtual-concierge-button.component'; +import { TeamQuickActionsComponent } from './team-quick-actions.component'; +import { TeamScheduleFiltersComponent } from './team-schedule-filters.component'; +import { TeamScheduleListComponent } from './team-schedule-list.component'; +import { TeamScheduleTableComponent } from './team-schedule-table.component'; + +@Component({ + selector: 'team-schedule', + template: ` +
+ @if (!hide_nav) { + + } +
+
+ + +
+
+
+
+ +
+ @if (!hide_nav) { + + } +
+
+ `, + styles: [``], + imports: [ + TopbarComponent, + FooterMenuComponent, + VirtualConciergeButtonComponent, + TeamQuickActionsComponent, + TeamScheduleFiltersComponent, + TeamScheduleTableComponent, + TeamScheduleListComponent, + ], +}) +export class TeamScheduleComponent { + /** Set from the `hide_nav` URL parameter when the app is embedded */ + public get hide_nav() { + return localStorage.getItem('PlaceOS.hide_nav') === 'true'; + } +} diff --git a/apps/workplace/src/app/team-schedule/team-schedule.service.ts b/apps/workplace/src/app/team-schedule/team-schedule.service.ts new file mode 100644 index 0000000000..b53fc14b55 --- /dev/null +++ b/apps/workplace/src/app/team-schedule/team-schedule.service.ts @@ -0,0 +1,694 @@ +import { + computed, + effect, + inject, + Injectable, + signal, + untracked, +} from '@angular/core'; +import { queryBookings } from '@placeos/bookings'; +import { + Booking, + currentUser, + firstTruthyValueFrom, + OrganisationService, + SETTING_KEYS, + SettingsService, + StaffUser, +} from '@placeos/common'; +import { PlaceMetadata, showMetadata, showUser } from '@placeos/ts-client'; +import { + addDays, + addWeeks, + eachDayOfInterval, + endOfDay, + format, + getUnixTime, + isSameDay, + startOfDay, + startOfWeek, + subWeeks, +} from 'date-fns'; +import { catchError, lastValueFrom, of } from 'rxjs'; +import { + DayStatus, + DeskBooking, + LocationStatus, + TeamMember, + USER_LOCATIONS, +} from './common'; + +export type ViewFilter = 'all' | 'favorites' | 'team'; + +export interface TeamScheduleFilters { + date: number; + day_index: number; // -1 means auto-select today + view: ViewFilter; + search: string; + department: string | null; + office: string | null; + status: LocationStatus | null; +} + +const DEFAULT_FILTERS: TeamScheduleFilters = { + date: Date.now(), + day_index: -1, + view: 'all', + search: '', + department: null, + office: null, + status: null, +}; + +@Injectable({ + providedIn: 'root', +}) +export class TeamScheduleService { + private readonly _org = inject(OrganisationService); + private readonly _settings = inject(SettingsService); + + // Filter state + private readonly _filters = signal({ + ...DEFAULT_FILTERS, + }); + public readonly filters = this._filters.asReadonly(); + + // Loading state - starts true, set to false after initial load completes + public readonly loading = signal(true); + private _initialized = false; + /** Colleagues from the contacts of the user */ + private _users: StaffUser[] = []; + /** Number of the last booking load. A response for an old load is dropped */ + private _request_id = 0; + + // Selection mode state + public readonly select_mode = signal(false); + private readonly _selected_member_ids = signal>(new Set()); + + // Store favorite and team member IDs + private readonly _favorite_ids = signal>(new Set()); + private readonly _team_member_ids = signal>(new Set()); + + /** + * Start of the selected week. The value is a number, so a filter change + * in the same week (search, department, status, view) does not change it. + */ + private readonly _week_start = computed(() => + startOfWeek(this._filters().date, { weekStartsOn: 1 }).valueOf(), + ); + + // Week days computed from selected date + public readonly week_days = computed(() => { + const start = this._week_start(); + const days = eachDayOfInterval({ + start, + end: addDays(start, 4), + }); + return days.map((date) => ({ + date: date.valueOf(), + is_today: isSameDay(date, new Date()), + })); + }); + + // Auto-select today's index when it's in the current week + public readonly active_day_index = computed(() => { + const manual_index = this._filters().day_index; + if (manual_index >= 0) return manual_index; + + const days = this.week_days(); + const today_index = days.findIndex((d) => d.is_today); + return today_index >= 0 ? today_index : 0; + }); + + // Team members from API + private readonly _team_members = signal([]); + + // Available filter options derived from data + public readonly departments = computed(() => { + const members = this._team_members(); + const depts = new Set(members.map((m) => m.department).filter(Boolean)); + return Array.from(depts).sort() as string[]; + }); + + public readonly offices = computed(() => { + const members = this._team_members(); + const offices = new Set( + members.map((m) => m.current_building).filter(Boolean), + ); + return Array.from(offices).sort() as string[]; + }); + + public readonly statuses = computed(() => { + return Object.keys(USER_LOCATIONS) as LocationStatus[]; + }); + + // Filtered members based on all filters + public readonly filtered_members = computed(() => { + let members = this._team_members(); + const { + view, + search: raw_search, + department, + office, + status, + } = this._filters(); + const search = raw_search.toLowerCase().trim(); + const day_index = this.active_day_index(); + + // View filter + if (view === 'favorites') { + members = members.filter((m) => m.is_favorite); + } else if (view === 'team') { + members = members.filter((m) => m.is_my_team); + } + + // Search filter + if (search) { + members = members.filter( + (m) => + m.user.name.toLowerCase().includes(search) || + m.user.email.toLowerCase().includes(search) || + m.department?.toLowerCase().includes(search), + ); + } + + // Department filter + if (department) { + members = members.filter((m) => m.department === department); + } + + // Office filter + if (office) { + members = members.filter((m) => m.current_building === office); + } + + // Status filter + if (status) { + members = members.filter( + (m) => m.statuses[day_index]?.status === status, + ); + } + + return members; + }); + + public readonly total_members = computed(() => this._team_members().length); + public readonly booked_count = computed(() => { + const members = this._team_members(); + const day_index = this.active_day_index(); + return members.filter((m) => m.statuses[day_index]?.status === 'office') + .length; + }); + + // Selected members for group booking + public readonly selected_members = computed(() => { + const ids = this._selected_member_ids(); + return this._team_members().filter((m) => ids.has(m.user.id)); + }); + + public readonly selected_count = computed( + () => this._selected_member_ids().size, + ); + + // Actions + public setFilters(filters: Partial) { + this._filters.set({ ...this._filters(), ...filters }); + } + + public resetFilters() { + this._filters.set({ ...DEFAULT_FILTERS, date: Date.now() }); + } + + public previousWeek() { + const current = this._filters(); + this._filters.set({ + ...current, + date: subWeeks(current.date, 1).valueOf(), + day_index: 0, + }); + } + + public nextWeek() { + const current = this._filters(); + this._filters.set({ + ...current, + date: addWeeks(current.date, 1).valueOf(), + day_index: 0, + }); + } + + public toggleFavorite(member: TeamMember) { + if (member.is_favorite) { + this.removeFavorite(member.user as StaffUser); + } else { + this.addFavorite(member.user as StaffUser); + } + } + + /** Check if a user is a favourite */ + public isFavorite(user: StaffUser | { email: string }): boolean { + const user_id = user.email; + return this._favorite_ids().has(user_id); + } + + /** Check if a user is a team member */ + public isTeamMember(user: StaffUser | { email: string }): boolean { + const user_id = user.email; + return this._team_member_ids().has(user_id); + } + + /** Toggle favourite status for a user by email */ + public toggleFavoriteByUser(user: StaffUser | { email: string }) { + if (this.isFavorite(user)) { + this.removeFavorite(user as StaffUser); + } else { + this.addFavorite(user as StaffUser); + } + } + + /** Toggle team member status for a user by email */ + public toggleTeamMemberByUser(user: StaffUser | { email: string }) { + if (this.isTeamMember(user)) { + this.removeTeamMember(user as StaffUser); + } else { + this.addTeamMember(user as StaffUser); + } + } + + public getLocationStyle(status: LocationStatus) { + return USER_LOCATIONS[status] || USER_LOCATIONS.unspecified; + } + + public getMemberStatus(member: TeamMember): DayStatus | undefined { + return member.statuses[this.active_day_index()]; + } + + // Selection mode methods + public toggleSelectMode() { + const new_mode = !this.select_mode(); + this.select_mode.set(new_mode); + if (!new_mode) { + this._selected_member_ids.set(new Set()); + } + } + + public clearSelection() { + this._selected_member_ids.set(new Set()); + this.select_mode.set(false); + } + + public toggleMemberSelection(member: TeamMember) { + const ids = new Set(this._selected_member_ids()); + if (ids.has(member.user.id)) { + ids.delete(member.user.id); + } else { + ids.add(member.user.id); + } + this._selected_member_ids.set(ids); + } + + public isMemberSelected(member: TeamMember): boolean { + return this._selected_member_ids().has(member.user.id); + } + + public getTeamMembers(): TeamMember[] { + return this._team_members().filter((m) => m.is_my_team); + } + + constructor() { + // Initialize data when org service is ready + this._initializeData(); + + // Re-fetch bookings when the week changes + effect(() => { + this._week_start(); + untracked(() => this._refreshBookings()); + }); + } + + private async _initializeData() { + await this._org.waitUntilInitialised(); + await this._loadUsers(); + } + + /** Load colleagues and team members, then fetch their bookings */ + private async _loadUsers() { + this.loading.set(true); + try { + // Fetch colleagues (from user's contacts metadata) + this._users = await this._fetchColleagues(); + } catch (error) { + console.error('Error loading team schedule data:', error); + } + // Set before the bookings load, so a week change in that time + // starts a new load. + this._initialized = true; + await this._updateMembers(); + } + + /** Refresh bookings for existing users when week changes */ + private async _refreshBookings() { + if (!this._initialized || this._users.length === 0) return; + await this._updateMembers(); + } + + /** Load the bookings of the colleagues for the selected week */ + private async _updateMembers() { + const request_id = ++this._request_id; + this.loading.set(true); + try { + // Favourites and team members are subsets of the colleagues. + // Read the IDs from the settings in case they changed. + const favorite_ids = this._getFavoriteIds(); + this._favorite_ids.set(favorite_ids); + const team_ids = this._getTeamMemberIds(); + this._team_member_ids.set(team_ids); + const team_members = await this._buildTeamMembers( + this._users, + favorite_ids, + team_ids, + ); + // A newer load started. Do not show the bookings of an old week. + if (request_id !== this._request_id) return; + this._team_members.set(team_members); + } catch (error) { + console.error('Error loading team schedule bookings:', error); + } finally { + if (request_id === this._request_id) this.loading.set(false); + } + } + + /** Fetch colleagues from user's contacts metadata */ + private async _fetchColleagues(): Promise { + try { + const user = currentUser(); + if (!user?.id) return []; + + const metadata: Partial = await showMetadata( + user.id, + 'contacts', + ).catch(() => ({})); + const list: Partial[] = + metadata?.details instanceof Array ? metadata.details : []; + + // Fetch full user details for each contact + const users = await Promise.all( + list.map((contact) => + showUser(contact.email).catch(() => contact), + ), + ); + return users.map((u) => new StaffUser(u)); + } catch (error) { + console.error('Error fetching colleagues:', error); + return []; + } + } + + /** Get favourite team member IDs from user settings */ + private _getFavoriteIds(): Set { + const ids = + this._settings.get(SETTING_KEYS.FAVORITE_TEAM_MEMBERS) || + []; + return new Set(ids); + } + + /** Get team member IDs from user settings */ + private _getTeamMemberIds(): Set { + const ids = + this._settings.get(SETTING_KEYS.TEAM_MEMBERS) || []; + return new Set(ids); + } + + /** + * Map user work location preference to our LocationStatus type. + * @param location - The location string from work preferences (wfh, wfo, aol, ooo, etc.) + * @returns The corresponding LocationStatus + */ + private _mapWorkLocationToStatus( + location: string | undefined, + ): LocationStatus { + if (!location) return 'unspecified'; + switch (location) { + case 'wfh': + return 'wfh'; + case 'wfo': + return 'office'; + case 'aol': + return 'on_leave'; + case 'sick': + return 'off_sick'; + case 'ooo': + return 'unspecified'; + default: + return 'unspecified'; + } + } + + /** + * Get user's work location for a specific date based on their work preferences. + * Checks overrides first, then falls back to day-of-week preferences. + * @param user - The StaffUser to check + * @param date - The date to check (timestamp) + * @returns The location string or undefined if not set + */ + private _getUserWorkLocationForDate( + user: StaffUser, + date: number, + ): string | undefined { + const date_obj = new Date(date); + const date_string = format(date_obj, 'yyyy-MM-dd'); + const day_of_week = date_obj.getDay() as 0 | 1 | 2 | 3 | 4 | 5 | 6; + + // Check for date-specific override first + const override = user.work_overrides?.[date_string]; + if (override?.blocks?.length) { + // Use the first block's location (or could use midday time) + return override.blocks[0].location; + } + + // Fall back to day-of-week preference + const day_preference = user.work_preferences?.find( + (pref) => pref.day_of_week === day_of_week, + ); + if (day_preference?.blocks?.length) { + // Use the first block's location + return day_preference.blocks[0].location; + } + + return undefined; + } + + /** Build TeamMember objects with booking data for each user */ + private async _buildTeamMembers( + users: StaffUser[], + favorite_ids: Set, + team_ids: Set, + ): Promise { + const week_days = this.week_days(); + const building = this._org.building; + + // Get the week's date range for querying bookings + const week_start = startOfDay(week_days[0].date); + const week_end = endOfDay(week_days[week_days.length - 1].date); + + // Fetch desk bookings for all users in parallel + const bookings_promises = users.map((user) => + queryBookings({ + email: user.email, + type: 'desk', + period_start: getUnixTime(week_start), + period_end: getUnixTime(week_end), + }).catch(() => [] as Booking[]), + ); + + const all_bookings = await Promise.all(bookings_promises); + + // Build team members with their booking data + return users.map((user, index) => { + const user_bookings = all_bookings[index]; + const user_id = user.email || user.id; + + // Build day statuses from work preferences and bookings + const day_statuses: DayStatus[] = week_days.map((day) => { + const day_start = startOfDay(day.date); + const day_end = endOfDay(day.date); + + // Find booking for this day + const booking = user_bookings.find( + (b) => + b.date >= day_start.valueOf() && + b.date < day_end.valueOf(), + ); + + // Get user's work location preference for this day + const work_location = this._getUserWorkLocationForDate( + user, + day.date, + ); + let status: LocationStatus = + this._mapWorkLocationToStatus(work_location); + let desk_booking: DeskBooking | undefined; + + // If user has a desk booking, override status to 'office' and include booking details + if (booking) { + status = 'office'; + // Find building name from zones + const booking_building = this._org.buildings.find((bld) => + booking.zones?.includes(bld.id), + ); + // Find level from zones + const booking_level = this._org.levels.find((lvl) => + booking.zones?.includes(lvl.id), + ); + + desk_booking = { + building_name: + booking_building?.display_name || + booking_building?.name || + '', + desk_code: + booking.asset_name || booking.description || '', + desk_id: booking.asset_id, + level_id: booking_level?.id, + date: booking.date, + duration: booking.duration, + }; + } + + return { + date: day.date, + status, + booking: desk_booking, + }; + }); + + // Get current day's status for current building/desk + const current_status = day_statuses.find((s) => + isSameDay(s.date, new Date()), + ); + const current_booking = current_status?.booking; + + return { + user, + is_favorite: favorite_ids.has(user_id), + is_my_team: team_ids.has(user_id), + department: user.department, + current_building: current_booking?.building_name, + current_desk: current_booking?.desk_code, + statuses: day_statuses, + }; + }); + } + + /** Add a user to favorites */ + public addFavorite(user: StaffUser) { + const user_id = user.email || user.id; + const current_favorites = + this._settings.get(SETTING_KEYS.FAVORITE_TEAM_MEMBERS) || + []; + + // Don't add duplicates + if (current_favorites.includes(user_id)) return; + + const updated = [...current_favorites, user_id]; + this._settings.saveUserSetting( + SETTING_KEYS.FAVORITE_TEAM_MEMBERS, + updated, + ); + + // Update local state + const favorite_ids = new Set(this._favorite_ids()); + favorite_ids.add(user_id); + this._favorite_ids.set(favorite_ids); + + // Update member's favorite status + const members = this._team_members().map((m) => + m.user.email === user.email ? { ...m, is_favorite: true } : m, + ); + this._team_members.set(members); + } + + /** Remove a user from favorites */ + public removeFavorite(user: StaffUser) { + const user_id = user.email || user.id; + const current_favorites = + this._settings.get(SETTING_KEYS.FAVORITE_TEAM_MEMBERS) || + []; + + const updated = current_favorites.filter((id) => id !== user_id); + this._settings.saveUserSetting( + SETTING_KEYS.FAVORITE_TEAM_MEMBERS, + updated, + ); + + // Update local state + const favorite_ids = new Set(this._favorite_ids()); + favorite_ids.delete(user_id); + this._favorite_ids.set(favorite_ids); + + // Update member's favorite status + const members = this._team_members().map((m) => + m.user.email === user.email ? { ...m, is_favorite: false } : m, + ); + this._team_members.set(members); + } + + /** Toggle a user's team member status */ + public toggleTeamMember(member: TeamMember) { + if (member.is_my_team) { + this.removeTeamMember(member.user as StaffUser); + } else { + this.addTeamMember(member.user as StaffUser); + } + } + + /** Add a user to team members */ + public addTeamMember(user: StaffUser) { + const user_id = user.email || user.id; + const current_team = + this._settings.get(SETTING_KEYS.TEAM_MEMBERS) || []; + + // Don't add duplicates + if (current_team.includes(user_id)) return; + + const updated = [...current_team, user_id]; + this._settings.saveUserSetting(SETTING_KEYS.TEAM_MEMBERS, updated); + + // Update local state + const team_ids = new Set(this._team_member_ids()); + team_ids.add(user_id); + this._team_member_ids.set(team_ids); + + // Update member's team status + const members = this._team_members().map((m) => + m.user.email === user.email ? { ...m, is_my_team: true } : m, + ); + this._team_members.set(members); + } + + /** Remove a user from team members */ + public removeTeamMember(user: StaffUser) { + const user_id = user.email || user.id; + const current_team = + this._settings.get(SETTING_KEYS.TEAM_MEMBERS) || []; + + const updated = current_team.filter((id) => id !== user_id); + this._settings.saveUserSetting(SETTING_KEYS.TEAM_MEMBERS, updated); + + // Update local state + const team_ids = new Set(this._team_member_ids()); + team_ids.delete(user_id); + this._team_member_ids.set(team_ids); + + // Update member's team status + const members = this._team_members().map((m) => + m.user.email === user.email ? { ...m, is_my_team: false } : m, + ); + this._team_members.set(members); + } + + /** Refresh all data */ + public async refresh() { + await this._loadUsers(); + } +} diff --git a/apps/workplace/src/environments/settings.schema.json b/apps/workplace/src/environments/settings.schema.json index d45caf5a78..120eca906d 100644 --- a/apps/workplace/src/environments/settings.schema.json +++ b/apps/workplace/src/environments/settings.schema.json @@ -485,6 +485,14 @@ "type": "boolean", "description": "Whether multi-day recurrence options should be available in parking flows" }, + "show_waitlist": { + "type": "boolean", + "description": "Whether unallocated parking bookings within the active waitlist week are shown as waitlisted. Defaults to `true`" + }, + "waitlist_week_start": { + "$ref": "#/$defs/weekly_boundary", + "description": "Day and time at which the active parking waitlist week starts. Defaults to Friday at 18:00" + }, "request_submission_notes_html": { "type": "string", "description": "Optional sanitized HTML content shown in the parking request summary and submission section." @@ -548,6 +556,10 @@ "type": "boolean", "description": "Whether users must select a parking space restriction in the parking request flow." }, + "show_special_needs": { + "type": "boolean", + "description": "Whether the special needs reason and document fields show to each user who selects the `special` request type. When `false`, only parking users with the special needs flag see them." + }, "show_status_details": { "type": "boolean", "description": "Whether the explore parking tooltip should show availability status details such as status text, assigned users, and plate numbers." @@ -988,9 +1000,71 @@ "description": "Whether locker booking times should use the building's timezone. Overrides app.bookings.use_building_timezone" } } + }, + "virtual_concierge": { + "type": "object", + "description": "Data associated with virtual concierge rendering", + "properties": { + "url": { + "type": "string", + "description": "URL of the virtual concierge page to embed. The button is hidden when this is empty." + }, + "icon": { + "type": "string", + "description": "URL of the image for the virtual concierge button. A default icon shows when this is empty." + }, + "display": { + "type": "string", + "enum": ["fullscreen", "sidebar"], + "description": "How the virtual concierge embed opens from workplace pages." + }, + "side": { + "type": "string", + "enum": ["left", "right"], + "description": "Which side of the screen the virtual concierge sidebar opens on when sidebar display is enabled." + }, + "prompts": { + "type": "array", + "description": "Suggested prompts shown in the virtual concierge UI.", + "items": { "type": "string" } + } + } } }, "$defs": { + "weekly_boundary": { + "type": "object", + "description": "A weekly day and wall-clock time boundary", + "required": ["day", "hour", "minute"], + "properties": { + "day": { + "type": "number", + "enum": [0, 1, 2, 3, 4, 5, 6], + "enumNames": [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday" + ], + "description": "Day of the week. `0` is Sunday through `6` for Saturday" + }, + "hour": { + "type": "number", + "minimum": 0, + "maximum": 23, + "description": "Hour of the day in 24-hour time" + }, + "minute": { + "type": "number", + "minimum": 0, + "maximum": 59, + "description": "Minute of the hour" + } + } + }, "bookable_hours": { "type": "object", "description": "Bookable time window as hours of the day (0-24). For example, `8` is 8:00 AM and `19` is 7:00 PM.", diff --git a/apps/workplace/src/environments/settings.ts b/apps/workplace/src/environments/settings.ts index 17f5c55052..ca0867a11e 100644 --- a/apps/workplace/src/environments/settings.ts +++ b/apps/workplace/src/environments/settings.ts @@ -156,7 +156,7 @@ const app = { name: 'Workplace', title: 'Workplace Application', description: 'PlaceOS Workplace UI written with Angular Framework', - short_name: 'WorkMate', + short_name: 'Workplace', logo_light: 'assets/logo-light.svg', logo_dark: 'assets/logo-dark.svg', locales: [ @@ -247,6 +247,7 @@ const app = { parking: { allow_all_day: true, allow_recurrence: true, + waitlist_week_start: { day: 5, hour: 18, minute: 0 }, request_submission_notes_html: '', vehicle_types: [ { id: 'car', name: 'BOOKINGS.PARKING_VEHICLE_CAR' }, @@ -263,6 +264,9 @@ const app = { require_plate_number: false, require_space_restriction: true, }, + visitors: { + allow_all_day: true, + }, analytics: { enabled: true, tracking_id: '', @@ -272,6 +276,15 @@ const app = { }, hide_contacts: false, schedule, + virtual_concierge: { + display: 'fullscreen', + side: 'left', + prompts: [ + 'Book a meeting room tomorrow at 2pm for 4 people', + 'Find me an available desk near a window', + 'Reserve parking for Friday morning', + ], + }, }; /** * ROOT SETTIGNS diff --git a/apps/workplace/src/test-setup.ts b/apps/workplace/src/test-setup.ts index cfd12bbcff..bd3442b2e3 100644 --- a/apps/workplace/src/test-setup.ts +++ b/apps/workplace/src/test-setup.ts @@ -9,6 +9,14 @@ global.TextEncoder = TextEncoder; global.TextDecoder = TextDecoder; installTestStorage(); +if (!Element.prototype.scrollTo) { + Element.prototype.scrollTo = vi.fn(); +} + +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn(); +} + // Under the zoneless unit-test builder, `fixture.detectChanges()` only // refreshes views that were explicitly marked dirty. Plain host-property // changes (`setHostInput`), overridden getters and reassigned signals do not diff --git a/apps/workplace/src/tests/book/auto-assigned-desk-modal.component.spec.ts b/apps/workplace/src/tests/book/auto-assigned-desk-modal.component.spec.ts new file mode 100644 index 0000000000..51fa4c987c --- /dev/null +++ b/apps/workplace/src/tests/book/auto-assigned-desk-modal.component.spec.ts @@ -0,0 +1,123 @@ +import { signal, WritableSignal } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { BookingFormService } from '@placeos/bookings'; +import { OrganisationService, SettingsService } from '@placeos/common'; +import { MockProvider } from 'ng-mocks'; + +import { AutoAssignedDeskModalComponent } from '../../app/book/desk-flow/auto-assigned-desk-modal.component'; + +describe('AutoAssignedDeskModalComponent', () => { + let spectator: Spectator; + let model: WritableSignal; + + const desk_resource = { + id: 'desk-123', + name: 'Desk 123', + map_id: 'desk-map-123', + zone: { id: 'level-1', parent_id: 'building-1' }, + tags: [], + }; + + const createComponent = createComponentFactory({ + component: AutoAssignedDeskModalComponent, + detectChanges: false, + shallow: true, + providers: [ + MockProvider(BookingFormService, { + setOptions: vi.fn(), + postForm: vi.fn(() => Promise.resolve()) as any, + postFormForGroup: vi.fn(() => Promise.resolve()) as any, + listResources: vi.fn(async () => [desk_resource]) as any, + listAvailableResources: vi.fn( + async () => [desk_resource], + ) as any, + options: signal({ type: 'desk' }), + resources: signal([desk_resource]), + available_resources: signal([desk_resource]), + model: (() => { + model = signal({ + booking_type: 'desk', + date: Date.now(), + duration: 60, + all_day: false, + resources: [], + asset_id: '', + }); + return model; + })(), + } as any), + MockProvider(OrganisationService, { + buildings: [{ id: 'building-1', name: 'Building 1' }], + levels: [{ id: 'level-1', name: 'Level 1' }], + levelWithID: vi.fn(() => ({ id: 'level-1' })), + } as any), + MockProvider(SettingsService, { + get: vi.fn((key: string) => + key === 'app.desks.default_duration' ? 60 : undefined, + ), + } as any), + MockProvider(Router, { + navigate: vi.fn(() => Promise.resolve(true)), + }), + MockProvider(MatDialogRef, { + close: vi.fn(), + }), + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + // The providers hold one model for the whole file + model.set({ + booking_type: 'desk', + date: Date.now(), + duration: 60, + all_day: false, + resources: [], + asset_id: '', + }); + spectator = createComponent(); + }); + + it('should search for a desk with an all-day booking', async () => { + await spectator.component.ngOnInit(); + + expect(model().all_day).toBe(true); + expect(spectator.component.assigned_desk()).toEqual(desk_resource); + }); + + it('should keep the assigned desk out of the shared form until the user confirms', async () => { + await spectator.component.ngOnInit(); + + expect(model().asset_id).toBe(''); + expect(model().resources).toEqual([]); + + await spectator.component.confirm(); + + expect(model().asset_id).toBe('desk-123'); + expect(model().resources).toEqual([desk_resource]); + expect(spectator.inject(BookingFormService).postForm).toHaveBeenCalled(); + }); + + it('should restore the shared form when the modal closes without a booking', async () => { + const initial_form = model(); + await spectator.component.ngOnInit(); + + spectator.fixture.destroy(); + + expect(model()).toEqual(initial_form); + expect(model().all_day).toBe(false); + }); + + it('should not restore the shared form after a booking', async () => { + await spectator.component.ngOnInit(); + await spectator.component.confirm(); + const booked_form = model(); + + spectator.fixture.destroy(); + + expect(model()).toBe(booked_form); + }); +}); diff --git a/apps/workplace/src/tests/book/desk-flow-new.component.spec.ts b/apps/workplace/src/tests/book/desk-flow-new.component.spec.ts new file mode 100644 index 0000000000..b215494d45 --- /dev/null +++ b/apps/workplace/src/tests/book/desk-flow-new.component.spec.ts @@ -0,0 +1,458 @@ +import { Injector, signal, WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + BookingForm, + BookingFormValue, + BookingFormService, + generateBookingForm, +} from '@placeos/bookings'; +import { + Booking, + EMPTY_USER, + OrganisationService, + setCurrentUser, + StaffUser, + User, +} from '@placeos/common'; +import { MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; +import { + listChildMetadata, + querySystemsWithEmails, + showSystem, + authority, +} from '@placeos/ts-client'; +import { DeskFlowNewComponent } from '../../app/book/desk-flow-new/desk-flow.component'; + +vi.mock('@placeos/ts-client', { spy: true }); + +describe('DeskFlowNewComponent', () => { + let spectator: Spectator; + let model: WritableSignal; + let form: BookingForm; + let query_params: Record; + let resource_list: any[]; + let active_building: WritableSignal; + let options: WritableSignal; + /** Booking that the form service holds for an edit */ + let edit_booking: Booking | null; + let auto_allocation: boolean; + const building_list = [{ id: 'building-1' }, { id: 'building-2' }]; + const desk_resource = { + id: 'desk-123', + name: 'Desk 123', + features: [], + zone: { id: 'level-1', parent_id: 'building-1' }, + }; + const group_members = [ + new User({ email: 'host@example.com' }), + new User({ email: 'member@example.com' }), + ]; + const group_siblings = [ + new Booking({ id: 'bkn-1', user_email: 'host@example.com' }), + new Booking({ id: 'bkn-2', user_email: 'member@example.com' }), + ]; + const waitFor = async (condition: () => boolean, timeout = 1000) => { + const end = Date.now() + timeout; + while (!condition() && Date.now() < end) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + }; + + const createComponent = createComponentFactory({ + component: DeskFlowNewComponent, + detectChanges: false, + shallow: true, + providers: [ + { + provide: BookingFormService, + useFactory: () => { + const injector = TestBed.inject(Injector); + const refs = TestBed.runInInjectionContext(() => + generateBookingForm(undefined, injector), + ); + form = refs.form; + model = refs.model; + model.update((m) => ({ + ...m, + booking_type: 'desk', + resources: [], + asset_id: '', + })); + options = signal({ type: 'desk' }); + return { + form, + model, + view: signal('form'), + options, + loading: signal(false), + resources: signal(resource_list), + available_resources: signal(resource_list), + get booking() { + return edit_booking; + }, + get auto_allocation() { + return auto_allocation; + }, + setView: vi.fn(), + setOptions: vi.fn((value: any) => + options.update((o) => ({ ...o, ...value })), + ), + autoAllocateDesk: vi.fn(async () => + model.update((m) => ({ + ...m, + resources: [desk_resource], + asset_id: desk_resource.id, + })), + ), + loadGroupMembersForBooking: vi.fn( + async () => group_members, + ), + loadGroupSiblings: vi.fn(async () => group_siblings), + editFormForGroup: vi.fn(() => Promise.resolve()), + newForm: vi.fn(() => { + model.update((m) => ({ + ...m, + booking_type: 'desk', + resources: [], + asset_id: '', + })); + }), + postForm: vi.fn(() => Promise.resolve()), + postFormForGroup: vi.fn(() => Promise.resolve()), + listResources: vi.fn(async () => resource_list), + listAvailableResources: vi.fn( + async () => resource_list, + ), + }; + }, + }, + MockProvider(Router, { + navigate: vi.fn(() => Promise.resolve(true)), + }), + MockProvider(ActivatedRoute, { + paramMap: of({ + has: (key: string) => key === 'step', + get: (key: string) => (key === 'step' ? 'form' : null), + }), + queryParamMap: of({ + has: (key: string) => key in query_params, + get: (key: string) => query_params[key] || null, + }), + } as any), + { + provide: OrganisationService, + useFactory: () => { + active_building = signal({ id: 'building-1' }); + const buildings = signal(building_list) as any; + buildings.find = building_list.find.bind(building_list); + buildings[Symbol.iterator] = function* () { + yield* building_list; + }; + return { + initialised: signal(true), + waitUntilInitialised: vi.fn(async () => undefined), + active_building, + active_buildings: signal(building_list), + active_region: signal(null), + region_list: signal([]), + buildings, + levelWithID: vi.fn((zones = []) => + zones.includes('level-1') + ? { id: 'level-1', map_id: 'level-map-1' } + : null, + ), + levelsForBuilding: vi.fn(() => [ + { + id: 'level-1', + parent_id: 'building-1', + tags: [], + }, + { + id: 'level-2', + parent_id: 'building-2', + tags: [], + }, + ]), + levelsForRegion: vi.fn(() => []), + find: vi.fn((id: string) => + ['building-1', 'building-2'].includes(id) + ? { id } + : null, + ), + get building() { + return active_building(); + }, + set building(value) { + active_building.set(value); + }, + }; + }, + }, + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + resource_list = [desk_resource]; + vi.mocked(listChildMetadata).mockResolvedValue([] as any); + vi.mocked(showSystem).mockReturnValue( + of({ + id: 'room-1', + email: 'room@example.com', + map_id: 'room-map-1', + zones: ['level-1'], + }) as any, + ); + vi.mocked(querySystemsWithEmails).mockReturnValue( + of({ data: [] }) as any, + ); + query_params = { asset_id: 'desk-123' }; + edit_booking = null; + auto_allocation = false; + }); + + it('should hydrate the selected desk from the asset_id query param', async () => { + spectator = createComponent(); + await spectator.component.ngOnInit(); + await new Promise((resolve) => setTimeout(resolve, 75)); + + expect( + spectator.inject(BookingFormService).setOptions, + ).toHaveBeenCalledWith({ type: 'desk', zones: ['level-1'] }); + expect(spectator.inject(OrganisationService).find).toHaveBeenCalledWith( + 'building-1', + ); + expect(spectator.inject(OrganisationService).active_building()).toEqual({ + id: 'building-1', + }); + expect(model().resources).toEqual([desk_resource]); + expect(model().asset_id).toBe('desk-123'); + }); + + it('should initialise new desk bookings through the booking form service', async () => { + spectator = createComponent(); + await spectator.component.ngOnInit(); + + expect( + spectator.inject(BookingFormService).newForm, + ).toHaveBeenCalledWith('desk'); + }); + + it('should wait for the selected desk to load from the resources stream', async () => { + resource_list = []; + spectator = createComponent(); + + await spectator.component.ngOnInit(); + await Promise.resolve(); + expect(model().asset_id).toBe(''); + + resource_list = [desk_resource]; + await new Promise((resolve) => setTimeout(resolve, 75)); + + expect(model().asset_id).toBe('desk-123'); + expect(model().resources).toEqual([desk_resource]); + }); + + it('should update the active building when the query param desk is in another building', async () => { + const other_building_desk = { + id: 'desk-456', + name: 'Desk 456', + features: [], + }; + resource_list = []; + vi.mocked(listChildMetadata).mockImplementation( + (building_id: string) => + Promise.resolve( + building_id === 'building-2' + ? [ + { + metadata: { + desks: { details: [other_building_desk] }, + }, + zone: { + id: 'level-2', + parent_id: 'building-2', + }, + }, + ] + : [], + ) as any, + ); + + spectator = createComponent(); + query_params = { asset_id: 'desk-456' }; + + await spectator.component.ngOnInit(); + await waitFor(() => model().asset_id === 'desk-456'); + + expect(spectator.inject(OrganisationService).active_building()).toEqual({ + id: 'building-2', + }); + expect(model().resources).toEqual([ + expect.objectContaining({ + id: 'desk-456', + name: 'Desk 456', + zone: { id: 'level-2', parent_id: 'building-2' }, + }), + ]); + expect(model().asset_id).toBe('desk-456'); + }); + + it('should select a nearby desk by resource id when no map id is available', async () => { + const desk_without_map_id = { + id: 'desk-nearby', + name: 'Nearby Desk', + features: [], + zone: { id: 'level-1', parent_id: 'building-1' }, + }; + vi.mocked(authority).mockReturnValue({} as any); + const fetch_spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue({ + ok: true, + text: () => Promise.resolve(''), + } as any); + resource_list = [desk_without_map_id]; + query_params = {}; + + spectator = createComponent(); + (spectator.component as any)._space_pipe = { + transform: vi.fn(() => + Promise.resolve({ + id: 'room-1', + map_id: 'room-map-1', + zones: ['level-1'], + }), + ), + }; + + await (spectator.component as any)._initNearbyDeskBooking( + 'room-1', + Date.now(), + ); + + expect(fetch_spy).toHaveBeenCalledWith('level-map-1', {}); + expect(model().resources).toEqual([desk_without_map_id]); + expect(model().asset_id).toBe('desk-nearby'); + }); + + describe('confirmBooking', () => { + let service: BookingFormService; + /** Fill the form with a valid desk selection */ + const selectDesk = () => + model.update((m) => ({ + ...m, + date: Date.now() + 60 * 60 * 1000, + duration: 60, + resources: [desk_resource], + asset_id: desk_resource.id, + })); + + beforeEach(() => { + setCurrentUser( + new StaffUser({ id: 'me', email: 'me@example.com' }), + ); + query_params = {}; + spectator = createComponent(); + service = spectator.inject(BookingFormService); + }); + + it('should save a single booking for the current user', async () => { + selectDesk(); + + await spectator.component.confirmBooking(); + + expect(service.postForm).toHaveBeenCalled(); + expect(service.postFormForGroup).not.toHaveBeenCalled(); + }); + + it('should not save when the desk is not selected', async () => { + await spectator.component.confirmBooking(); + + expect(service.postForm).not.toHaveBeenCalled(); + }); + + it('should not save a booking for another person when no person is selected', async () => { + selectDesk(); + spectator.component.active_form.set('other'); + model.update((m) => ({ ...m, user: EMPTY_USER })); + + await spectator.component.confirmBooking(); + + expect(service.postForm).not.toHaveBeenCalled(); + + model.update((m) => ({ + ...m, + user: new User({ email: 'other@example.com' }), + })); + await spectator.component.confirmBooking(); + + expect(service.postForm).toHaveBeenCalled(); + }); + + it('should reset the booking type after a booking is saved', () => { + const view = service.view as unknown as WritableSignal; + spectator.component.active_form.set('group'); + + view.set('success'); + spectator.detectChanges(); + + expect(spectator.component.active_form()).toBe('single'); + view.set('form'); + }); + + it('should save a new group booking for the group', async () => { + selectDesk(); + options.set({ type: 'desk', group: true, members: group_members }); + + await spectator.component.confirmBooking(); + + expect(service.postFormForGroup).toHaveBeenCalled(); + expect(service.postForm).not.toHaveBeenCalled(); + }); + + it('should update each member when the user edits a group booking', async () => { + edit_booking = new Booking({ + id: 'bkn-1', + booking_type: 'desk', + parent_id: 'bkn-parent', + }); + model.update((m) => ({ ...m, id: 'bkn-1' })); + selectDesk(); + + spectator.component.ngOnInit(); + await waitFor(() => options().group === true); + + expect(options().members).toEqual(group_members); + + await spectator.component.confirmBooking(); + + expect(service.loadGroupSiblings).toHaveBeenCalledWith( + edit_booking, + ); + expect(service.editFormForGroup).toHaveBeenCalledWith( + group_siblings, + ); + expect(service.postForm).not.toHaveBeenCalled(); + expect(service.postFormForGroup).not.toHaveBeenCalled(); + }); + + it('should allocate the desk on confirm when auto allocation is on', async () => { + auto_allocation = true; + model.update((m) => ({ + ...m, + date: Date.now() + 60 * 60 * 1000, + duration: 60, + })); + + await spectator.component.confirmBooking(); + + expect(service.autoAllocateDesk).toHaveBeenCalled(); + expect(model().asset_id).toBe(desk_resource.id); + expect(service.postForm).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/workplace/src/tests/book/desk-flow-new/desk-flow-auto-assign.component.spec.ts b/apps/workplace/src/tests/book/desk-flow-new/desk-flow-auto-assign.component.spec.ts new file mode 100644 index 0000000000..c8ffeac271 --- /dev/null +++ b/apps/workplace/src/tests/book/desk-flow-new/desk-flow-auto-assign.component.spec.ts @@ -0,0 +1,120 @@ +import { signal, WritableSignal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { BookingFormService } from '@placeos/bookings'; +import { MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; + +import { DeskFlowAutoAssignComponent } from '../../../app/book/desk-flow-new/desk-flow-auto-assign.component'; +import { AutoAssignedDeskModalComponent } from '../../../app/book/desk-flow/auto-assigned-desk-modal.component'; + +describe('DeskFlowAutoAssignComponent', () => { + let spectator: Spectator; + let model: WritableSignal; + let set_view: any; + let dialog_open: any; + let after_closed_value: any; + let component_instance: { + show_close: { set: any }; + date: { set: any }; + duration: { set: any }; + }; + + const createComponent = createComponentFactory({ + component: DeskFlowAutoAssignComponent, + detectChanges: false, + shallow: true, + providers: [ + MockProvider(BookingFormService, {} as any), + MockProvider(MatDialog, {} as any), + MockProvider(Router, { navigate: vi.fn() }), + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + model = signal({ date: Date.now(), duration: 60 }); + set_view = vi.fn(); + after_closed_value = false; + component_instance = { + show_close: { set: vi.fn() }, + date: { set: vi.fn() }, + duration: { set: vi.fn() }, + }; + const dialog_ref = { + componentInstance: component_instance, + afterClosed: vi.fn(() => of(after_closed_value)), + }; + dialog_open = vi.fn(() => dialog_ref); + spectator = createComponent({ + providers: [ + MockProvider(BookingFormService, { + model, + setView: set_view, + } as any), + { provide: MatDialog, useValue: { open: dialog_open } }, + ], + }); + }); + + it('should open the auto-assigned desk modal with fullscreen config', () => { + spectator.component.autoAssignDesk(); + + expect(dialog_open).toHaveBeenCalledWith( + AutoAssignedDeskModalComponent, + expect.objectContaining({ + maxWidth: '100vw', + maxHeight: '100vh', + panelClass: 'auto-assigned-desk-modal', + }), + ); + expect(component_instance.show_close.set).toHaveBeenCalledWith(true); + }); + + it('should not open the modal when the form has an error', () => { + spectator.setInput('error', 'Select a person'); + + spectator.component.autoAssignDesk(); + + expect(dialog_open).not.toHaveBeenCalled(); + }); + + it('should pass the current date and duration to the modal when set', () => { + model.set({ date: 1234, duration: 90 }); + + spectator.component.autoAssignDesk(); + + expect(component_instance.date.set).toHaveBeenCalledWith(1234); + expect(component_instance.duration.set).toHaveBeenCalledWith(90); + }); + + it('should not set date or duration on the modal when they are falsy', () => { + model.set({ date: 0, duration: 0 }); + + spectator.component.autoAssignDesk(); + + expect(component_instance.date.set).not.toHaveBeenCalled(); + expect(component_instance.duration.set).not.toHaveBeenCalled(); + }); + + it('should move the booking form to the success view when the modal confirms', () => { + after_closed_value = true; + + spectator.component.autoAssignDesk(); + + expect(set_view).toHaveBeenCalledWith('success'); + }); + + it('should not change the view when the modal is dismissed without confirming', () => { + after_closed_value = false; + + spectator.component.autoAssignDesk(); + + expect(set_view).not.toHaveBeenCalled(); + }); + + it('should expose the booking form model through the model getter', () => { + expect(spectator.component.model).toBe(model); + }); +}); diff --git a/apps/workplace/src/tests/book/desk-flow-new/desk-flow-details.component.spec.ts b/apps/workplace/src/tests/book/desk-flow-new/desk-flow-details.component.spec.ts new file mode 100644 index 0000000000..ca1adf085e --- /dev/null +++ b/apps/workplace/src/tests/book/desk-flow-new/desk-flow-details.component.spec.ts @@ -0,0 +1,265 @@ +import { Injector, signal, WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { + BookingForm, + BookingFormValue, + BookingFormService, + generateBookingForm, +} from '@placeos/bookings'; +import { + currentUser, + isEmptyUser, + OrganisationService, + settingSignal, + SettingsService, + User, +} from '@placeos/common'; +import { addDays, endOfDay } from 'date-fns'; +import { MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; + +import { DeskFlowDetailsComponent } from '../../../app/book/desk-flow-new/desk-flow-details.component'; + +describe('DeskFlowDetailsComponent', () => { + let spectator: Spectator; + let form: BookingForm; + let model: WritableSignal; + let options: WritableSignal; + let set_options: any; + let set_user_values: ReturnType; + let dialog_open: any; + let after_closed_value: any; + let dialog_instance: { + date: any; + duration: any; + users: any; + }; + + const createComponent = createComponentFactory({ + component: DeskFlowDetailsComponent, + detectChanges: false, + shallow: true, + providers: [ + { + provide: BookingFormService, + useFactory: () => { + const injector = TestBed.inject(Injector); + const refs = TestBed.runInInjectionContext(() => + generateBookingForm(undefined, injector), + ); + form = refs.form; + model = refs.model; + model.update((m) => ({ + ...m, + id: '', + date: Date.now(), + duration: 60, + user: null, + attendees: [], + })); + options = signal({ type: 'desk' }); + set_options = vi.fn((update: any) => + options.update((o) => ({ ...o, ...update })), + ); + return { + form, + model, + options, + setOptions: set_options, + setUserValues: set_user_values, + }; + }, + }, + MockProvider(OrganisationService, { + building: { id: 'bld-1', timezone: 'Australia/Sydney' }, + } as any), + MockProvider(SettingsService, { + get: vi.fn(() => false), + } as any), + MockProvider(MatDialog, {} as any), + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + set_user_values = vi.fn(); + after_closed_value = null; + dialog_instance = { + date: vi.fn(() => 5000), + duration: vi.fn(() => 45), + users: vi.fn(() => [{ email: 'user@a.com' }]), + }; + const dialog_ref = { + componentInstance: dialog_instance, + afterClosed: vi.fn(() => of(after_closed_value)), + }; + dialog_open = vi.fn(() => dialog_ref); + spectator = createComponent({ + providers: [ + { provide: MatDialog, useValue: { open: dialog_open } }, + ], + }); + }); + + it('should reset to a personal booking when selecting the single form', () => { + model.update((m) => ({ ...m, user: null, attendees: ['a'] as any })); + + spectator.component.setActiveForm('single'); + + expect(spectator.component.active_form()).toBe('single'); + expect(set_options).toHaveBeenCalledWith({ group: false }); + expect(model().user).toEqual(currentUser()); + expect(model().attendees).toEqual([]); + }); + + it('should keep the current user as host but preserve attendees for group bookings', () => { + model.update((m) => ({ ...m, user: null, attendees: ['a'] as any })); + + spectator.component.setActiveForm('group'); + + expect(spectator.component.active_form()).toBe('group'); + expect(set_options).toHaveBeenCalledWith({ group: true }); + expect(model().user).toEqual(currentUser()); + expect(model().attendees).toEqual(['a']); + }); + + it('should clear the user and attendees when booking for someone else', () => { + model.update((m) => ({ + ...m, + user: currentUser(), + attendees: ['a'] as any, + })); + + spectator.component.setActiveForm('other'); + + expect(spectator.component.active_form()).toBe('other'); + expect(set_options).toHaveBeenCalledWith({ group: false }); + // The signal form normalises a cleared user back to the empty-user + // placeholder, so assert the host is no longer a real user. + expect(isEmptyUser(model().user)).toBe(true); + expect(model().attendees).toEqual([]); + }); + + it('should switch to the group form when the options indicate a group booking', () => { + expect(spectator.component.active_form()).toBe('single'); + + options.set({ type: 'desk', group: true }); + TestBed.flushEffects(); + + expect(spectator.component.active_form()).toBe('group'); + }); + + it('should send recurrence changes to the service as user edits', () => { + spectator.component.onRecurrenceChange({ recurrence_type: 'weekly' }); + + expect(set_user_values).toHaveBeenCalledWith({ + recurrence_type: 'weekly', + }); + }); + + it('should send the first-instance date to the service as a user edit', () => { + spectator.component.onFirstInstanceChange(98765); + + expect(set_user_values).toHaveBeenCalledWith({ date: 98765 }); + }); + + it('should not require a locker for a saved `{}` value', () => { + // Some saved desk bookings hold `{}`, which an earlier default of + // the form wrote. + model.update((m) => ({ ...m, secondary_resource: {} })); + expect(spectator.component.requires_locker()).toBe(false); + + model.update((m) => ({ ...m, secondary_resource: 'locker' })); + expect(spectator.component.requires_locker()).toBe(true); + }); + + it('should forward setOptions to the booking form service', () => { + const members = [new User({ email: 'x@a.com' })]; + spectator.component.setOptions({ members }); + + expect(set_options).toHaveBeenCalledWith({ members }); + }); + + it('should offer the group and book for other types only when the settings allow them', () => { + const typeButtons = () => + spectator + .queryAll('button[btn] > div') + .map((el) => el.textContent.trim()); + settingSignal('desks.allow_groups').set(true); + settingSignal('desks.can_book_for_others').set(true); + spectator.detectChanges(); + expect(typeButtons()).toEqual(['Single', 'Group', 'Book for other']); + + settingSignal('desks.allow_groups').set(false); + spectator.detectChanges(); + expect(typeButtons()).toEqual(['Single', 'Book for other']); + + settingSignal('desks.can_book_for_others').set(false); + settingSignal('bookings.can_book_for_others').set(false); + spectator.detectChanges(); + expect(typeButtons()).toEqual([]); + }); + + it('should limit the selectable end date to the configured available period', () => { + spectator.component.available_days.set(14); + + expect(spectator.component.end_date()).toBe( + endOfDay(addDays(Date.now(), 14)).valueOf(), + ); + }); + + it('should open the availability modal seeded with the group members and host', () => { + options.set({ type: 'desk', members: [{ email: 'member@a.com' }] }); + model.update((m) => ({ ...m, date: 111, duration: 30 })); + + spectator.component.findAvailableTime(); + + expect(dialog_open).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + data: expect.objectContaining({ + users: [{ email: 'member@a.com' }], + host: currentUser(), + date: 111, + duration: 30, + }), + }), + ); + }); + + it('should apply the chosen time and members when the availability modal returns a result', () => { + after_closed_value = true; + + spectator.component.findAvailableTime(); + + expect(model().date).toBe(5000); + expect(model().duration).toBe(45); + expect(set_options).toHaveBeenCalledWith({ + members: [{ email: 'user@a.com' }], + }); + }); + + it('should leave the booking untouched when the availability modal is dismissed', () => { + after_closed_value = null; + model.update((m) => ({ ...m, date: 111, duration: 30 })); + + spectator.component.findAvailableTime(); + + expect(model().date).toBe(111); + expect(model().duration).toBe(30); + }); + + it('should use the building timezone only when the building-timezone setting is enabled', () => { + const settings = spectator.inject(SettingsService); + (settings.get as any).mockImplementation( + (key: string) => key === 'app.desks.use_building_timezone', + ); + + expect(spectator.component.timezone).toBe('Australia/Sydney'); + + (settings.get as any).mockReturnValue(false); + expect(spectator.component.timezone).toBe(''); + }); +}); diff --git a/apps/workplace/src/tests/book/desk-flow-new/desk-flow-select-map.component.spec.ts b/apps/workplace/src/tests/book/desk-flow-new/desk-flow-select-map.component.spec.ts new file mode 100644 index 0000000000..4d51ca9a05 --- /dev/null +++ b/apps/workplace/src/tests/book/desk-flow-new/desk-flow-select-map.component.spec.ts @@ -0,0 +1,347 @@ +import { signal, WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { BookingFormService } from '@placeos/bookings'; +import { Booking, OrganisationService, SettingsService } from '@placeos/common'; +import { InteractiveMapComponent } from '@placeos/components'; +import { DEFAULT_COLOURS } from '@placeos/explore'; +import { mockComponent } from '@placeos/common/tests'; +import * as ts_client from '@placeos/ts-client'; +import { MockProvider } from 'ng-mocks'; + +vi.mock('@placeos/ts-client', { spy: true }); + +import { DeskFlowSelectMapComponent } from '../../../app/book/desk-flow-new/desk-flow-select-map.component'; + +describe('DeskFlowSelectMapComponent', () => { + let spectator: Spectator; + let resources: WritableSignal; + let available_resources: WritableSignal; + let model: WritableSignal; + let options: WritableSignal; + let use_region: WritableSignal; + let show_users: WritableSignal; + let set_options: any; + let settings_get: any; + + const level_1 = { + id: 'level-1', + parent_id: 'bld-1', + name: 'Level 1', + display_name: 'Level 1', + map_id: 'map-1', + tags: [], + location: '1,2', + }; + const parking_level = { + id: 'level-p', + parent_id: 'bld-1', + name: 'Parking', + display_name: 'Parking', + map_id: 'map-p', + tags: ['parking'], + location: '', + }; + const level_2 = { + id: 'level-2', + parent_id: 'bld-1', + name: 'Level 2', + display_name: 'Level 2', + map_id: 'map-2', + tags: [], + location: '3,4', + }; + + const desk_1 = { id: 'desk-1', map_id: 'd-1', zone: { id: 'level-1' } }; + const desk_2 = { id: 'desk-2', map_id: 'd-2', zone: { id: 'level-1' } }; + + const createComponent = createComponentFactory({ + component: DeskFlowSelectMapComponent, + detectChanges: false, + declarations: [mockComponent(InteractiveMapComponent)], + providers: [ + { + provide: BookingFormService, + useFactory: () => ({ + resources, + available_resources, + model, + options, + setOptions: (set_options = vi.fn()), + bookingWindow: ({ date, duration }) => ({ + start: date, + end: date + duration * 60 * 1000, + }), + }), + }, + MockProvider(OrganisationService, { + active_region: signal(null) as any, + active_building: signal({ id: 'bld-1' }) as any, + levelsForRegion: vi.fn(() => [ + level_1, + parking_level, + level_2, + ]), + levelsForBuilding: vi.fn(() => [ + level_1, + parking_level, + level_2, + ]), + levelWithID: vi.fn((zones: string[] = []) => + zones?.includes('level-1') ? level_1 : null, + ), + buildings: [{ id: 'bld-1', location: '5,6' }] as any, + } as any), + MockProvider(SettingsService, { + get: (settings_get = vi.fn(() => ({}))), + signal: ((key: string) => + key === 'desks.show_users' + ? show_users + : use_region) as any, + } as any), + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + use_region = signal(false); + show_users = signal(true); + vi.mocked(ts_client.query).mockResolvedValue({ + data: [], + total: 0, + next: null, + } as any); + resources = signal([desk_1, desk_2]); + available_resources = signal([desk_1]); + model = signal({ resources: [] }); + options = signal({ zones: ['level-1'] }); + spectator = createComponent(); + }); + + it('should list only bookable levels that hold resources and exclude parking', () => { + const levels = spectator.component.levels(); + + expect(levels).toEqual([level_1]); + expect(levels).not.toContain(parking_level); + expect(levels).not.toContain(level_2); + }); + + it('should expose the map id from the active level', () => { + expect(spectator.component.map_url()).toBe(''); + spectator.component.level.set(level_1 as any); + expect(spectator.component.map_url()).toBe('map-1'); + }); + + it('should resolve the selected desk from the selected ids', () => { + spectator.setInput('selected_items', ['desk-1']); + expect(spectator.component.selected_desk()).toEqual( + expect.objectContaining({ id: 'desk-1' }), + ); + + spectator.setInput('selected_items', ['missing']); + expect(spectator.component.selected_desk()).toBeNull(); + }); + + it('should keep the active desk visible even when it is missing from availability', () => { + const active_desk = { id: 'desk-active', map_id: 'd-active' }; + model.set({ resources: [active_desk] }); + + const available = spectator.component.actions().map((a) => a.id); + // active desk is prepended to the available list + expect(available).toContain('d-active'); + expect(available).toContain('d-1'); + }); + + it('should build a map action for each available desk', () => { + const actions = spectator.component.actions(); + + expect(actions.length).toBe(1); + expect(actions[0]).toEqual( + expect.objectContaining({ + id: 'd-1', + action: ['touchend', 'mouseup'], + }), + ); + expect(typeof actions[0].callback).toBe('function'); + }); + + it('should fall back to the desk id for map actions', () => { + const desk_without_map_id = { + id: 'desk-without-map-id', + name: 'Desk without map id', + zone: { id: 'level-1' }, + }; + resources.set([desk_without_map_id]); + available_resources.set([desk_without_map_id]); + + expect(spectator.component.actions()[0].id).toBe('desk-without-map-id'); + }); + + it('should expose desk names and availability to map hover features', () => { + const features = spectator.component.features(); + + expect(features).toHaveLength(2); + expect(features[0]).toEqual( + expect.objectContaining({ + location: 'd-1', + full_size: true, + }), + ); + expect(features[0].data).toEqual( + expect.objectContaining({ + id: 'desk-1', + map_id: 'd-1', + }), + ); + expect((features[0].data.status as () => string)()).toBe('free'); + expect((features[1].data.status as () => string)()).toBe('busy'); + + spectator.setInput('selected_items', ['desk-1']); + expect((features[0].data.status as () => string)()).toBe('pending'); + }); + + it('should show who booked each desk in the search window', async () => { + const date = new Date(2026, 8, 23, 9).valueOf(); + model.set({ resources: [], date, duration: 60 }); + spectator.component.level.set(level_1 as any); + vi.mocked(ts_client.query).mockResolvedValue({ + data: [ + new Booking({ + id: 'b-1', + asset_id: 'desk-2', + user_name: 'Taylor', + }), + new Booking({ + id: 'b-2', + asset_id: 'desk-2', + user_name: 'Jordan', + }), + new Booking({ + id: 'b-3', + asset_id: 'desk-1', + user_name: 'Casey', + deleted: true, + }), + ], + total: 3, + next: null, + } as any); + TestBed.flushEffects(); + await vi.waitFor(() => + expect(spectator.component.features()[1].data.user()).toBe( + 'Taylor, Jordan', + ), + ); + + expect(ts_client.query).toHaveBeenCalledWith( + expect.objectContaining({ + query_params: expect.objectContaining({ + type: 'desk', + zones: 'level-1', + period_start: date / 1000, + period_end: date / 1000 + 3600, + }), + }), + ); + // Cancelled bookings do not name the desk user + expect(spectator.component.features()[0].data.user()).toBe(''); + }); + + it('should not load desk users when users are hidden', () => { + show_users.set(false); + model.set({ resources: [], date: Date.now(), duration: 60 }); + spectator.component.level.set(level_1 as any); + TestBed.flushEffects(); + + expect(ts_client.query).not.toHaveBeenCalled(); + }); + + it('should emit the desk when a map action callback fires', () => { + const emitted: any[] = []; + spectator.output('item_selected').subscribe((v) => emitted.push(v)); + + spectator.component.actions()[0].callback(); + + expect(emitted[0]).toEqual(expect.objectContaining({ id: 'desk-1' })); + }); + + it('should style available desks free, selected pending, and others busy', () => { + spectator.setInput('selected_items', ['desk-1']); + const styles = spectator.component.styles(); + + // desk-1 is available and selected -> pending + expect(styles['#d-1'].fill).toBe(DEFAULT_COLOURS.pending); + // desk-2 is not available -> busy + expect(styles['#d-2'].fill).toBe(DEFAULT_COLOURS.busy); + }); + + it('should style an available but unselected desk as free', () => { + const styles = spectator.component.styles(); + expect(styles['#d-1'].fill).toBe(DEFAULT_COLOURS.free); + }); + + it('should apply configured colours when provided', () => { + settings_get.mockReturnValue({ 'space-free': '#123456' }); + const styles = spectator.component.styles(); + expect(styles['#d-1'].fill).toBe('#123456'); + }); + + it('should update options and coordinates when a level is picked', () => { + spectator.component.setLevel(level_1 as any); + + expect(set_options).toHaveBeenCalledWith({ + zones: ['level-1'], + zone_id: 'level-1', + }); + expect(spectator.component.level()).toEqual(level_1); + expect(spectator.component.coordinates()).toEqual({ + latitude: 1, + longitude: 2, + }); + }); + + it('should fall back to the building location when the level has none', () => { + const level_no_loc = { ...level_1, location: '' }; + spectator.component.setLevel(level_no_loc as any); + + expect(spectator.component.coordinates()).toEqual({ + latitude: 5, + longitude: 6, + }); + }); + + it('should clamp zoom to the supported range', () => { + spectator.component.setZoom(50); + expect(spectator.component.zoom()).toBe(10); + spectator.component.setZoom(0.1); + expect(spectator.component.zoom()).toBe(0.5); + }); + + it('should reset the map view', () => { + spectator.component.zoom.set(5); + spectator.component.center.set({ x: 0.1, y: 0.9 }); + + spectator.component.resetMap(); + + expect(spectator.component.zoom()).toBe(1); + expect(spectator.component.center()).toEqual({ x: 0.5, y: 0.5 }); + }); + + it('should list levels for the active region when region mode is enabled', () => { + use_region.set(true); + // recreate so the region-mode signal is read from construction + spectator = createComponent(); + + expect(spectator.component.levels()).toEqual([level_1]); + expect( + spectator.inject(OrganisationService).levelsForRegion, + ).toHaveBeenCalled(); + }); + + it('should sync the active level from the form option zones on init', () => { + options.set({ zones: ['level-1'] }); + TestBed.flushEffects(); + + expect(spectator.component.level()).toEqual(level_1); + }); +}); diff --git a/apps/workplace/src/tests/book/desk-flow-select-list.component.spec.ts b/apps/workplace/src/tests/book/desk-flow-select-list.component.spec.ts new file mode 100644 index 0000000000..82444fb060 --- /dev/null +++ b/apps/workplace/src/tests/book/desk-flow-select-list.component.spec.ts @@ -0,0 +1,128 @@ +import { signal, WritableSignal } from '@angular/core'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { BookingFormService } from '@placeos/bookings'; +import { SettingsService } from '@placeos/common'; +import { MockProvider } from 'ng-mocks'; +import { DeskFlowSelectListComponent } from '../../app/book/desk-flow-new/desk-flow-select-list.component'; + +describe('DeskFlowSelectListComponent', () => { + let spectator: Spectator; + let model: WritableSignal; + let available_resources: WritableSignal; + + const createComponent = createComponentFactory({ + component: DeskFlowSelectListComponent, + detectChanges: false, + shallow: true, + providers: [ + MockProvider(BookingFormService, { + loading: signal(''), + available_resources: (() => { + available_resources = signal([]); + return available_resources; + })(), + model: (() => { + model = signal({ resources: [] }); + return model; + })(), + } as any), + MockProvider(SettingsService, { + get: vi.fn(), + saveUserSetting: vi.fn(), + }), + ], + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should keep the selected desk in the rendered list for new bookings', () => { + model.update((m) => ({ + ...m, + resources: [{ id: 'desk-123', name: 'Desk 123' }], + })); + + expect(spectator.component.available_items()).toEqual([ + { id: 'desk-123', name: 'Desk 123' }, + ]); + }); + + it('should keep the selected desk first once it is present in the real result set', () => { + const list = Array.from({ length: 20 }, (_, index) => ({ + id: `desk-${index + 1}`, + name: `Desk ${index + 1}`, + })); + spectator.setInput('promote_selected', true); + + model.update((m) => ({ + ...m, + resources: [{ id: 'desk-16', name: 'Desk 16' }], + })); + spectator.component.selected_items.set(['desk-16']); + spectator.detectChanges(); + + expect(spectator.component.page()).toBe(0); + expect(spectator.component.available_items()[0]).toEqual({ + id: 'desk-16', + name: 'Desk 16', + }); + + available_resources.set(list); + spectator.detectChanges(); + + expect(spectator.component.page()).toBe(0); + expect(spectator.component.available_items()[0]).toEqual({ + id: 'desk-16', + name: 'Desk 16', + }); + }); + + it('should keep list ordering when selection is not promoted', () => { + const list = Array.from({ length: 20 }, (_, index) => ({ + id: `desk-${index + 1}`, + name: `Desk ${index + 1}`, + })); + available_resources.set(list); + + model.update((m) => ({ + ...m, + resources: [{ id: 'desk-16', name: 'Desk 16' }], + })); + spectator.component.selected_items.set(['desk-16']); + spectator.detectChanges(); + + expect(spectator.component.available_items()[0]).toEqual({ + id: 'desk-1', + name: 'Desk 1', + }); + }); + + it('should promote the selected desk when the list changes after selection', () => { + const list = Array.from({ length: 20 }, (_, index) => ({ + id: `desk-${index + 1}`, + name: `Desk ${index + 1}`, + })); + available_resources.set(list); + + model.update((m) => ({ + ...m, + resources: [{ id: 'desk-16', name: 'Desk 16' }], + })); + spectator.component.selected_items.set(['desk-16']); + spectator.detectChanges(); + + expect(spectator.component.available_items()[0]).toEqual({ + id: 'desk-1', + name: 'Desk 1', + }); + + available_resources.set([...list].reverse()); + spectator.detectChanges(); + + expect(spectator.component.available_items()[0]).toEqual({ + id: 'desk-16', + name: 'Desk 16', + }); + }); +}); diff --git a/apps/workplace/src/tests/book/desk-flow-select.component.spec.ts b/apps/workplace/src/tests/book/desk-flow-select.component.spec.ts new file mode 100644 index 0000000000..a7e928c13c --- /dev/null +++ b/apps/workplace/src/tests/book/desk-flow-select.component.spec.ts @@ -0,0 +1,138 @@ +import { Injector, signal, WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { + BookingForm, + BookingFormService, + BookingFormValue, + generateBookingForm, +} from '@placeos/bookings'; +import { OrganisationService, SettingsService } from '@placeos/common'; +import { SpacesService } from '@placeos/events'; +import { DateFieldComponent } from '@placeos/form-fields'; +import { addDays, endOfDay } from 'date-fns'; +import { MockProvider } from 'ng-mocks'; +import { DeskFlowSelectComponent } from '../../app/book/desk-flow-new/desk-flow-select.component'; + +describe('DeskFlowSelectComponent', () => { + let spectator: Spectator; + let form: BookingForm; + let model: WritableSignal; + let active_building: WritableSignal; + + const createComponent = createComponentFactory({ + component: DeskFlowSelectComponent, + detectChanges: false, + shallow: true, + providers: [ + { + provide: BookingFormService, + useFactory: () => { + const injector = TestBed.inject(Injector); + const refs = TestBed.runInInjectionContext(() => + generateBookingForm(undefined, injector), + ); + form = refs.form; + model = refs.model; + model.update((m) => ({ + ...m, + date: Date.now(), + duration: 60, + all_day: false, + resources: [], + asset_id: '', + })); + return { + form, + model, + options: signal({ type: 'desk' }), + loading: signal(''), + resources: signal([]), + available_resources: signal([]), + setOptions: vi.fn(), + }; + }, + }, + MockProvider(OrganisationService, { + active_building: (() => { + active_building = signal({ id: 'bld-1' }); + return active_building; + })(), + active_buildings: signal([{ id: 'bld-1' }, { id: 'bld-2' }]), + buildings: [{ id: 'bld-1' }, { id: 'bld-2' }], + active_region: signal(null), + region_list: signal([]), + levelsForBuilding: vi.fn(() => []), + levelsForRegion: vi.fn(() => []), + building: { id: 'bld-1', timezone: '' }, + } as any), + MockProvider(SettingsService, { + get: vi.fn(() => false), + } as any), + MockProvider(SpacesService, {}), + ], + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should clear the selected desk when the building changes', () => { + model.update((m) => ({ + ...m, + resources: [{ id: 'desk-1', name: 'Desk 1' } as any], + asset_id: 'desk-1', + })); + spectator.component.ngOnInit(); + TestBed.flushEffects(); + spectator.component.selected.set(['desk-1']); + + active_building.set({ id: 'bld-2' }); + TestBed.flushEffects(); + + expect(model().resources).toEqual([]); + // Clearing the desk empties resources; the resource→asset sync then + // leaves `asset_id` falsy. + expect(model().asset_id).toBeFalsy(); + expect(spectator.component.selected()).toEqual([]); + }); + + it('should keep a selected desk that belongs to the new building', async () => { + model.update((m) => ({ + ...m, + resources: [ + { + id: 'desk-2', + name: 'Desk 2', + zone: { id: 'level-2', parent_id: 'bld-2' }, + } as any, + ], + asset_id: 'desk-2', + })); + spectator.component.ngOnInit(); + await spectator.fixture.whenStable(); + + active_building.set({ id: 'bld-2' }); + await spectator.fixture.whenStable(); + + expect(model().resources).toEqual([ + expect.objectContaining({ id: 'desk-2' }), + ]); + expect(model().asset_id).toBe('desk-2'); + expect(spectator.component.selected()).toEqual(['desk-2']); + }); + + it('should limit date selection to the configured available period', () => { + spectator.component.available_days.set(14); + spectator.detectChanges(); + + const end_date = spectator.component.end_date(); + const date_fields = spectator.queryAll(DateFieldComponent); + + expect(end_date).toBe(endOfDay(addDays(Date.now(), 14)).valueOf()); + expect(date_fields.length).toBe(2); + expect(date_fields.every((field) => field.to_date() === end_date)).toBe( + true, + ); + }); +}); diff --git a/apps/workplace/src/tests/book/desk-flow-success.component.spec.ts b/apps/workplace/src/tests/book/desk-flow-success.component.spec.ts index 20499c2dbb..c1169fde4c 100644 --- a/apps/workplace/src/tests/book/desk-flow-success.component.spec.ts +++ b/apps/workplace/src/tests/book/desk-flow-success.component.spec.ts @@ -15,7 +15,7 @@ import { BuildingPipe, LevelPipe, TranslatePipe } from '@placeos/components'; import * as ts_client_mod from '@placeos/ts-client'; import { MockPipe, MockProvider } from 'ng-mocks'; -import { NewDeskFlowSuccessComponent } from '../../app/book/desk-flow/desk-flow-success.component'; +import { NewDeskFlowSuccessComponent } from '../../app/book/desk-flow-new/desk-flow-success.component'; vi.mock('@placeos/ts-client', { spy: true }); diff --git a/apps/workplace/src/tests/book/desk-flow.component.spec.ts b/apps/workplace/src/tests/book/desk-flow.component.spec.ts index 1637210780..99e2aeee95 100644 --- a/apps/workplace/src/tests/book/desk-flow.component.spec.ts +++ b/apps/workplace/src/tests/book/desk-flow.component.spec.ts @@ -11,7 +11,7 @@ import * as ts_client from '@placeos/ts-client'; import { MockComponent, MockProvider } from 'ng-mocks'; import { NewDeskFlowComponent } from '../../app/book/desk-flow.component'; import { NewDeskFlowFormComponent } from '../../app/book/desk-flow/desk-flow-form.component'; -import { NewDeskFlowSuccessComponent } from '../../app/book/desk-flow/desk-flow-success.component'; +import { NewDeskFlowSuccessComponent } from '../../app/book/desk-flow-new/desk-flow-success.component'; vi.mock('@placeos/ts-client', { spy: true }); diff --git a/apps/workplace/src/tests/book/meeting-flow-new/meeting-flow-options.component.spec.ts b/apps/workplace/src/tests/book/meeting-flow-new/meeting-flow-options.component.spec.ts new file mode 100644 index 0000000000..b3a353ce05 --- /dev/null +++ b/apps/workplace/src/tests/book/meeting-flow-new/meeting-flow-options.component.spec.ts @@ -0,0 +1,325 @@ +import { Injector, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { CateringOrderStateService } from '@placeos/catering'; +import { + OrganisationService, + setCurrentUser, + setNotifyOutlet, + settingSignal, + SettingsService, +} from '@placeos/common'; +import { EventFormService, generateEventForm } from '@placeos/events'; +import { UserListFieldComponent } from '@placeos/form-fields'; +import { MockComponent, MockProvider } from 'ng-mocks'; +import { NEVER, of } from 'rxjs'; +import { MeetingFlowOptionsComponent } from '../../../app/book/meeting-flow-new/meeting-flow-options.component'; + +describe('MeetingFlowOptionsComponent', () => { + let spectator: Spectator; + let model: ReturnType>; + let available_menu: ReturnType>; + let charge_codes: ReturnType>; + let post_form: any; + let open_link_modal: any; + let navigate: any; + let dialog_open: any; + const snackbar = { + open: vi.fn(() => ({ + dismiss: vi.fn(), + onAction: () => ({ subscribe: vi.fn() }), + })), + }; + + const createComponent = createComponentFactory({ + component: MeetingFlowOptionsComponent, + detectChanges: false, + overrideComponents: [ + [ + MeetingFlowOptionsComponent, + { + remove: { imports: [UserListFieldComponent] }, + add: { imports: [MockComponent(UserListFieldComponent)] }, + }, + ], + ], + providers: [ + { + provide: EventFormService, + useFactory: () => { + const injector = TestBed.inject(Injector); + const refs = TestBed.runInInjectionContext(() => + generateEventForm(undefined, undefined, injector), + ); + refs.model.set({ ...refs.model(), ...model() }); + model = refs.model as any; + return { + model, + form: refs.form, + postForm: (post_form = vi.fn(() => Promise.resolve())), + openEventLinkModal: (open_link_modal = vi.fn()), + }; + }, + }, + { + provide: CateringOrderStateService, + useFactory: () => ({ + available_menu, + charge_codes, + }), + }, + MockProvider(SettingsService, { + get: vi.fn(() => false), + signal: vi.fn(() => signal(false)), + time_format: 'h:mm a', + } as any), + MockProvider(OrganisationService, { + levelWithID: vi.fn((ids: string[]) => + ids?.includes('level-1') + ? { id: 'level-1', display_name: 'Level 1' } + : null, + ), + buildings: [ + { id: 'bld-1', display_name: 'HQ' }, + ] as any, + get building() { + return { timezone: 'Australia/Sydney' }; + }, + } as any), + MockProvider(Router, { navigate: (navigate = vi.fn()) } as any), + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + setCurrentUser({ email: 'me@example.com' } as any); + setNotifyOutlet(snackbar as any, true); + model = signal({ + title: 'Sync', + host: 'host@example.com', + date: Date.now(), + duration: 60, + all_day: false, + resources: [{ id: 'space-1', name: 'Room 1' }], + recurrence: {}, + attendees: [], + catering: [], + }); + available_menu = signal([]); + charge_codes = signal([]); + settingSignal('events.allow_daily_allday_recurrence', false).set(false); + settingSignal('events.booking_unavailable').set(false); + settingSignal('events.no_standalone').set(false); + settingSignal('events.strict_capacity_check').set(false); + settingSignal('events.hide_attendees').set(false); + // The tests that render the template do not need these fields + settingSignal('events.hide_notes').set(true); + settingSignal('events.has_assets').set(false); + spectator = createComponent(); + // A component-imported dialog module provides a real MatDialog that + // shadows a root mock, so spy on the actual injected instance. + dialog_open = vi + .spyOn( + spectator.fixture.componentRef.injector.get(MatDialog), + 'open', + ) + .mockReturnValue({ afterClosed: () => of(null) } as any); + }); + + it('should flag multiday meetings by duration', () => { + expect(spectator.component.is_multiday).toBe(false); + model.update((m) => ({ ...m, duration: 48 * 60 })); + expect(spectator.component.is_multiday).toBe(true); + }); + + it('should reflect catering menu and charge code availability', () => { + expect(spectator.component.has_catering()).toBe(false); + expect(spectator.component.has_codes()).toBe(false); + available_menu.set([{ id: 'item-1' }] as any); + charge_codes.set(['CODE-1']); + expect(spectator.component.has_catering()).toBe(true); + expect(spectator.component.has_codes()).toBe(true); + }); + + it('should filter charge codes by the search term', () => { + charge_codes.set(['ALPHA', 'BETA', 'ALPINE']); + spectator.component.code_filter.set('alp'); + expect(spectator.component.filtered_codes()).toEqual([ + 'ALPHA', + 'ALPINE', + ]); + }); + + it('should build a space location from level and building', () => { + const location = spectator.component.getSpaceLocation({ + level: 'level-1', + zones: ['bld-1'], + }); + expect(location).toBe('Level 1, HQ'); + }); + + it('should return an empty location for an unknown space', () => { + expect(spectator.component.getSpaceLocation(null)).toBe(''); + }); + + it('should default the host to the current user before posting', async () => { + model.update((m) => ({ ...m, host: '' })); + await spectator.component.confirmBooking(); + expect(model().host).toBe('me@example.com'); + expect(post_form).toHaveBeenCalled(); + }); + + it('should block a daily all-day recurrence when not permitted', async () => { + settingSignal('events.allow_daily_allday_recurrence', false).set(false); + model.update((m) => ({ + ...m, + all_day: true, + recurrence: { pattern: 'daily' }, + })); + await spectator.component.confirmBooking(); + expect(snackbar.open).toHaveBeenCalled(); + expect(post_form).not.toHaveBeenCalled(); + }); + + it('should confirm before booking a meeting without a room', async () => { + dialog_open.mockReturnValue({ + componentInstance: { + event: of({ reason: 'done' }), + loading: signal(''), + }, + afterClosed: () => of(null), + close: vi.fn(), + }); + model.update((m) => ({ ...m, resources: [] })); + await spectator.component.confirmBooking(); + expect(dialog_open).toHaveBeenCalled(); + expect(post_form).toHaveBeenCalled(); + }); + + it('should abort a room-less booking if the confirmation is dismissed', async () => { + dialog_open.mockReturnValue({ + componentInstance: { + event: NEVER, + loading: signal(''), + }, + afterClosed: () => of({ reason: 'cancel' }), + close: vi.fn(), + }); + model.update((m) => ({ ...m, resources: [] })); + await spectator.component.confirmBooking(); + expect(post_form).not.toHaveBeenCalled(); + }); + + it('should open the booking link and not save when room booking is unavailable', async () => { + settingSignal('events.booking_unavailable').set(true); + + await spectator.component.confirmBooking(); + + expect(open_link_modal).toHaveBeenCalled(); + expect(post_form).not.toHaveBeenCalled(); + }); + + it('should not save a meeting without a room when a room is required', async () => { + settingSignal('events.no_standalone').set(true); + model.update((m) => ({ ...m, resources: [] })); + + await spectator.component.confirmBooking(); + + expect(dialog_open).not.toHaveBeenCalled(); + expect(post_form).not.toHaveBeenCalled(); + }); + + it('should not save more attendees than the rooms hold when the capacity check is strict', async () => { + settingSignal('events.strict_capacity_check').set(true); + model.update((m) => ({ + ...m, + resources: [{ id: 'space-1', name: 'Room 1', capacity: 2 }], + attendees: [{ email: 'a@example.com' }, { email: 'b@example.com' }], + })); + + await spectator.component.confirmBooking(); + expect(post_form).not.toHaveBeenCalled(); + + model.update((m) => ({ + ...m, + attendees: [{ email: 'a@example.com' }], + })); + await spectator.component.confirmBooking(); + expect(post_form).toHaveBeenCalled(); + }); + + it('should hide the attendees field when the setting hides it', () => { + spectator.detectChanges(); + expect(spectator.query('a-user-list-field')).toExist(); + + settingSignal('events.hide_attendees').set(true); + spectator.detectChanges(); + expect(spectator.query('a-user-list-field')).not.toExist(); + }); + + it('should allow a catering order only when a room is selected', () => { + available_menu.set([{ id: 'item-1' }] as any); + const addButton = () => + spectator.query('catering-list-field button'); + + spectator.detectChanges(); + expect(addButton().disabled).toBe(false); + + model.update((m) => ({ ...m, resources: [] })); + spectator.detectChanges(); + expect(addButton().disabled).toBe(true); + }); + + it('should navigate to success and notify on a successful booking', async () => { + await spectator.component.confirmBooking(); + expect(post_form).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(['/book', 'meeting', 'success']); + expect(snackbar.open).toHaveBeenCalled(); + expect(spectator.component.loading()).toBe(false); + }); + + it('should surface an error and reset loading when posting fails', async () => { + post_form.mockRejectedValue('boom'); + await expect(spectator.component.confirmBooking()).rejects.toBeDefined(); + expect(snackbar.open).toHaveBeenCalledWith( + 'boom', + 'OK', + expect.objectContaining({ panelClass: ['error'] }), + ); + expect(navigate).not.toHaveBeenCalled(); + expect(spectator.component.loading()).toBe(false); + }); + + it('should apply the chosen time and attendees from the availability dialog', () => { + const new_date = Date.now() + 3600_000; + dialog_open.mockReturnValue({ + afterClosed: () => of(true), + componentInstance: { + date: () => new_date, + users: () => [{ email: 'a@example.com' }], + duration: () => 90, + }, + }); + spectator.component.findAvailableTime(); + expect(dialog_open).toHaveBeenCalled(); + expect(model().date).toBe(new_date); + expect(model().duration).toBe(90); + expect(model().attendees).toEqual([{ email: 'a@example.com' }]); + }); + + it('should leave the model untouched when the availability dialog is cancelled', () => { + const original = model(); + dialog_open.mockReturnValue({ + afterClosed: () => of(null), + componentInstance: { + date: () => 0, + users: () => [], + duration: () => 0, + }, + }); + spectator.component.findAvailableTime(); + expect(model()).toBe(original); + }); +}); diff --git a/apps/workplace/src/tests/book/meeting-flow-new/meeting-flow-space-list.component.spec.ts b/apps/workplace/src/tests/book/meeting-flow-new/meeting-flow-space-list.component.spec.ts new file mode 100644 index 0000000000..cc8744cba7 --- /dev/null +++ b/apps/workplace/src/tests/book/meeting-flow-new/meeting-flow-space-list.component.spec.ts @@ -0,0 +1,153 @@ +import { signal } from '@angular/core'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { SETTING_KEYS, settingSignal, SettingsService } from '@placeos/common'; +import { LevelPipe } from '@placeos/components'; +import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; +import { EventFormService } from '@placeos/events'; +import { MockPipe, MockProvider } from 'ng-mocks'; +import { mockDirective } from '@placeos/common/tests'; +import { MeetingFlowSpaceListComponent } from '../../../app/book/meeting-flow-new/meeting-flow-space-list.component'; + +describe('MeetingFlowSpaceListComponent', () => { + let spectator: Spectator; + let loading: ReturnType>; + let available_spaces: ReturnType>; + let room_alerts: ReturnType>>; + let save_user_setting: any; + + const makeSpaces = (count: number) => + Array.from({ length: count }, (_, i) => ({ + id: `space-${i}`, + name: `Space ${i}`, + capacity: 4, + zones: ['level-1'], + images: [], + features: [], + })); + + const createComponent = createComponentFactory({ + component: MeetingFlowSpaceListComponent, + detectChanges: false, + overrideComponents: [ + [ + MeetingFlowSpaceListComponent, + { + remove: { + imports: [LevelPipe, AuthenticatedImageDirective], + }, + add: { + imports: [ + MockPipe(LevelPipe, () => ({ + display_name: 'Level 1', + }) as any) as any, + mockDirective(AuthenticatedImageDirective as any), + ], + }, + }, + ], + ], + providers: [ + { + provide: EventFormService, + useFactory: () => ({ + loading, + available_spaces, + room_alerts, + }), + }, + MockProvider(SettingsService, { + saveUserSetting: (save_user_setting = vi.fn()), + } as any), + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + loading = signal(false); + available_spaces = signal(makeSpaces(3)); + room_alerts = signal({}); + settingSignal(SETTING_KEYS.FAVORITE_ROOMS, [], true).set([]); + spectator = createComponent(); + }); + + it('should render an item per available space', () => { + spectator.detectChanges(); + expect(spectator.queryAll('li[space]').length).toBe(3); + }); + + it('should show the empty state when no spaces are available', () => { + available_spaces.set([]); + spectator.detectChanges(); + expect(spectator.query('[empty]')).toExist(); + expect(spectator.query('li[space]')).not.toExist(); + }); + + it('should show a loading indicator while spaces are loading', () => { + loading.set(true); + spectator.detectChanges(); + expect(spectator.query('[loading]')).toExist(); + expect(spectator.query('li[space]')).not.toExist(); + }); + + it('should emit the selected space when its button is clicked', () => { + const emitted: any[] = []; + spectator.output('space_selected').subscribe((v) => emitted.push(v)); + spectator.detectChanges(); + spectator.click(spectator.query('button[name="select-space"]')); + expect(emitted[0]).toEqual( + expect.objectContaining({ id: 'space-0' }), + ); + }); + + it('should compute pagination bounds from the space count', () => { + available_spaces.set(makeSpaces(25)); + expect(spectator.component.max_pages()).toBe(3); + expect(spectator.component.active_range()).toEqual([1, 10]); + + spectator.component.page.set(2); + expect(spectator.component.active_range()).toEqual([21, 25]); + }); + + it('should show the last page when the room list gets shorter than the selected page', () => { + available_spaces.set(makeSpaces(35)); + spectator.component.page.set(2); + spectator.detectChanges(); + expect(spectator.queryAll('li[space]').length).toBe(10); + + available_spaces.set(makeSpaces(4)); + spectator.detectChanges(); + + expect(spectator.queryAll('li[space]').length).toBe(4); + expect(spectator.component.active_range()).toEqual([1, 4]); + }); + + it('should only render a page worth of spaces', () => { + available_spaces.set(makeSpaces(25)); + spectator.detectChanges(); + expect(spectator.queryAll('li[space]').length).toBe(10); + }); + + it('should toggle a space in and out of favourites and persist it', () => { + const space = { id: 'space-0' } as any; + spectator.component.toggleFavourite(space); + expect(spectator.component.favourites()).toContain('space-0'); + expect(save_user_setting).toHaveBeenLastCalledWith( + SETTING_KEYS.FAVORITE_ROOMS, + ['space-0'], + ); + + spectator.component.toggleFavourite(space); + expect(spectator.component.favourites()).not.toContain('space-0'); + expect(save_user_setting).toHaveBeenLastCalledWith( + SETTING_KEYS.FAVORITE_ROOMS, + [], + ); + }); + + it('should disable selection for spaces flagged as closed', () => { + room_alerts.set({ 'space-0': ['closed', 'Room is closed'] }); + spectator.detectChanges(); + const first_button = spectator.query('button[name="select-space"]'); + expect(first_button).toHaveClass('pointer-events-none'); + }); +}); diff --git a/apps/workplace/src/tests/book/meeting-flow-new/meeting-flow-space-map.component.spec.ts b/apps/workplace/src/tests/book/meeting-flow-new/meeting-flow-space-map.component.spec.ts new file mode 100644 index 0000000000..546279cb2e --- /dev/null +++ b/apps/workplace/src/tests/book/meeting-flow-new/meeting-flow-space-map.component.spec.ts @@ -0,0 +1,174 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { + OrganisationService, + settingSignal, + SettingsService, +} from '@placeos/common'; +import { InteractiveMapComponent } from '@placeos/components'; +import { EventFormService } from '@placeos/events'; +import { MockProvider } from 'ng-mocks'; +import { mockComponent } from '@placeos/common/tests'; +import { MeetingFlowSpaceMapComponent } from '../../../app/book/meeting-flow-new/meeting-flow-space-map.component'; + +describe('MeetingFlowSpaceMapComponent', () => { + let spectator: Spectator; + let spaces: ReturnType>; + let available_spaces: ReturnType>; + let options: ReturnType>; + let set_options: any; + let settings_get: any; + + const level_1 = { + id: 'level-1', + parent_id: 'bld-1', + name: 'Level 1', + display_name: 'Level 1', + map_id: 'map-1', + tags: [], + location: '1,2', + }; + const parking_level = { + id: 'level-p', + parent_id: 'bld-1', + name: 'Parking', + display_name: 'Parking', + map_id: 'map-p', + tags: ['parking'], + location: '', + }; + + const createComponent = createComponentFactory({ + component: MeetingFlowSpaceMapComponent, + detectChanges: false, + declarations: [mockComponent(InteractiveMapComponent)], + providers: [ + { + provide: EventFormService, + useFactory: () => ({ + spaces, + available_spaces, + options, + setOptions: (set_options = vi.fn()), + }), + }, + MockProvider(OrganisationService, { + active_region: signal(null) as any, + active_building: signal({ id: 'bld-1' }) as any, + levelsForRegion: vi.fn(() => [level_1, parking_level]), + levelsForBuilding: vi.fn(() => [level_1, parking_level]), + levelWithID: vi.fn((zones: string[] = []) => + zones?.includes('level-1') ? level_1 : null, + ), + buildings: [{ id: 'bld-1', location: '5,6' }] as any, + } as any), + MockProvider(SettingsService, { + get: (settings_get = vi.fn(() => ({}))), + } as any), + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + spaces = signal([ + { id: 'space-1', map_id: 'sp-1', zones: ['level-1'] }, + { id: 'space-2', map_id: 'sp-2', zones: ['level-1'] }, + ]); + available_spaces = signal([ + { id: 'space-1', map_id: 'sp-1', zones: ['level-1'], images: [] }, + ]); + options = signal({ zones: ['level-1'] }); + settingSignal('use_region', false).set(false); + spectator = createComponent(); + }); + + it('should list bookable levels while excluding parking levels', () => { + const levels = spectator.component.levels(); + expect(levels).toEqual([level_1]); + expect(levels).not.toContain(parking_level); + }); + + it('should expose the map id from the active level', () => { + spectator.component.level.set(level_1 as any); + expect(spectator.component.map_url()).toBe('map-1'); + }); + + it('should resolve the selected space from the selected ids', () => { + spectator.setInput('selected_spaces', ['space-1']); + expect(spectator.component.selected_space()).toEqual( + expect.objectContaining({ id: 'space-1' }), + ); + + spectator.setInput('selected_spaces', ['missing']); + expect(spectator.component.selected_space()).toBeNull(); + }); + + it('should build map actions for each available space', () => { + const actions = spectator.component.actions(); + expect(actions.length).toBe(1); + expect(actions[0]).toEqual( + expect.objectContaining({ + id: 'sp-1', + action: ['touchend', 'mouseup'], + }), + ); + expect(typeof actions[0].callback).toBe('function'); + }); + + it('should emit the space when a map action callback fires', () => { + const emitted: any[] = []; + spectator.output('space_selected').subscribe((v) => emitted.push(v)); + spectator.component.actions()[0].callback(); + expect(emitted[0]).toEqual( + expect.objectContaining({ id: 'space-1' }), + ); + }); + + it('should style available spaces free, selected pending, and others busy', () => { + spectator.setInput('selected_spaces', ['space-1']); + const styles = spectator.component.styles(); + // space-1 is available and selected -> pending colour + expect(styles['#sp-1'].fill).toBe('#ffb300'); + // space-2 is not in available_spaces -> busy colour + expect(styles['#sp-2'].fill).toBe('#e53935'); + }); + + it('should apply configured colours when provided', () => { + settings_get.mockReturnValue({ 'space-free': '#123456' }); + // space-1 available but not selected -> free + const styles = spectator.component.styles(); + expect(styles['#sp-1'].fill).toBe('#123456'); + }); + + it('should update options and coordinates when a level is picked', () => { + spectator.component.setLevel(level_1 as any); + expect(set_options).toHaveBeenCalledWith({ zones: ['level-1'] }); + expect(spectator.component.level()).toEqual(level_1); + expect(spectator.component.coordinates()).toEqual({ + latitude: 1, + longitude: 2, + }); + }); + + it('should clamp zoom to the supported range', () => { + spectator.component.setZoom(50); + expect(spectator.component.zoom()).toBe(10); + spectator.component.setZoom(0.1); + expect(spectator.component.zoom()).toBe(0.5); + }); + + it('should reset the map view', () => { + spectator.component.zoom.set(5); + spectator.component.center.set({ x: 0.1, y: 0.9 }); + spectator.component.resetMap(); + expect(spectator.component.zoom()).toBe(1); + expect(spectator.component.center()).toEqual({ x: 0.5, y: 0.5 }); + }); + + it('should sync the active level from the form option zones', () => { + options.set({ zones: ['level-1'] }); + TestBed.flushEffects(); + expect(spectator.component.level()).toEqual(level_1); + }); +}); diff --git a/apps/workplace/src/tests/book/meeting-flow-new/meeting-flow-space-select.component.spec.ts b/apps/workplace/src/tests/book/meeting-flow-new/meeting-flow-space-select.component.spec.ts new file mode 100644 index 0000000000..8550b29634 --- /dev/null +++ b/apps/workplace/src/tests/book/meeting-flow-new/meeting-flow-space-select.component.spec.ts @@ -0,0 +1,288 @@ +import { Injector, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { + OrganisationService, + setNotifyOutlet, + settingSignal, + SettingsService, +} from '@placeos/common'; +import { + EventFormService, + generateEventForm, + SpacesService, +} from '@placeos/events'; +import { MockProvider } from 'ng-mocks'; +import { MeetingFlowSpaceSelectComponent } from '../../../app/book/meeting-flow-new/meeting-flow-space-select.component'; + +describe('MeetingFlowSpaceSelectComponent', () => { + let spectator: Spectator; + let model: ReturnType>; + let options: ReturnType>; + let filters: ReturnType>; + let spaces: ReturnType>; + let available_spaces: ReturnType>; + let set_options: any; + let set_filters: any; + let navigate: any; + let settings_get: any; + let org: any; + const snackbar = { + open: vi.fn(() => ({ + dismiss: vi.fn(), + onAction: () => ({ subscribe: vi.fn() }), + })), + }; + + const level_1 = { + id: 'level-1', + parent_id: 'bld-1', + name: 'Level 1', + display_name: 'Level 1', + tags: [], + }; + const level_2 = { + id: 'level-2', + parent_id: 'bld-1', + name: 'Level 2', + display_name: 'Level 2', + tags: [], + }; + const parking_level = { + id: 'level-p', + parent_id: 'bld-1', + name: 'Parking', + display_name: 'Parking', + tags: ['parking'], + }; + + const createComponent = createComponentFactory({ + component: MeetingFlowSpaceSelectComponent, + detectChanges: false, + providers: [ + { + provide: EventFormService, + useFactory: () => { + const injector = TestBed.inject(Injector); + const refs = TestBed.runInInjectionContext(() => + generateEventForm(undefined, undefined, injector), + ); + refs.model.set({ ...refs.model(), ...model() }); + model = refs.model as any; + return { + model, + form: refs.form, + options, + filters, + spaces, + available_spaces, + loading: signal(false), + room_alerts: signal({}), + setOptions: (set_options = vi.fn()), + setFilters: (set_filters = vi.fn()), + }; + }, + }, + MockProvider(OrganisationService, { + active_building: signal({ id: 'bld-1' }) as any, + active_buildings: signal([{ id: 'bld-1' }]) as any, + buildings: [{ id: 'bld-1' }] as any, + active_region: signal(null) as any, + region_list: signal([]) as any, + levelsForBuilding: vi.fn(() => [ + level_1, + level_2, + parking_level, + ]), + levelsForRegion: vi.fn(() => []), + get building() { + return { id: 'bld-1', timezone: '' }; + }, + set building(_v) {}, + get region() { + return null; + }, + set region(_v) {}, + } as any), + MockProvider(SettingsService, { + get: (settings_get = vi.fn(() => false)), + } as any), + MockProvider(SpacesService, { + features: signal(['whiteboard']) as any, + } as any), + MockProvider(Router, { navigate: (navigate = vi.fn()) } as any), + MockProvider(ActivatedRoute, {}), + ], + }); + + beforeEach(() => { + snackbar.open.mockClear(); + setNotifyOutlet(snackbar as any, true); + model = signal({ + date: Date.now(), + duration: 60, + resources: [], + }); + options = signal({ zones: ['level-1'] }); + filters = signal({ capacity: -1, features: [] }); + spaces = signal([ + { id: 'space-1', zones: ['level-1'], features: ['whiteboard'] }, + { id: 'space-2', zones: ['level-2'], features: ['tv'] }, + ]); + available_spaces = signal([ + { id: 'space-1', zones: ['level-1'], features: ['whiteboard'] }, + ]); + settingSignal('use_region', false).set(false); + spectator = createComponent(); + org = spectator.inject(OrganisationService); + }); + + it('should map selected resources to their ids', () => { + model.set({ resources: [{ id: 'space-1' }, { id: 'space-2' }] }); + expect(spectator.component.selected()).toEqual(['space-1', 'space-2']); + }); + + it('should report whether a space is selected', () => { + expect(spectator.component.has_space()).toBe(false); + model.set({ resources: [{ id: 'space-1' }] }); + expect(spectator.component.has_space()).toBe(true); + }); + + it('should list bookable levels excluding parking, sorted', () => { + // Both levels have a space assigned via zones + spaces.set([ + { id: 'space-1', zones: ['level-1'] }, + { id: 'space-2', zones: ['level-2'] }, + ]); + const levels = spectator.component.levels(); + expect(levels).toEqual([level_1, level_2]); + expect(levels).not.toContain(parking_level); + }); + + it('should aggregate space features with the service feature list', () => { + const features = spectator.component.features(); + expect(features).toEqual( + expect.arrayContaining(['whiteboard']), + ); + // unique - no duplicates + expect(features.filter((f) => f === 'whiteboard').length).toBe(1); + }); + + it('should add a feature filter when toggled on', () => { + spectator.component.toggleFeature('tv', true); + expect(set_filters).toHaveBeenCalledWith({ features: ['tv'] }); + }); + + it('should remove a feature filter when toggled off', () => { + filters.set({ features: ['tv', 'whiteboard'] }); + spectator.component.toggleFeature('tv', false); + expect(set_filters).toHaveBeenCalledWith({ features: ['whiteboard'] }); + }); + + it('should remove a single feature filter', () => { + filters.set({ features: ['tv', 'whiteboard'] }); + spectator.component.removeFeature('whiteboard'); + expect(set_filters).toHaveBeenCalledWith({ features: ['tv'] }); + }); + + it('should clear all feature filters', () => { + spectator.component.removeAllFeatures(); + expect(set_filters).toHaveBeenCalledWith({ features: [] }); + }); + + it('should switch to the map view and keep the current valid zone', () => { + options.set({ zones: ['level-2'] }); + spectator.component.setMapView(); + expect(spectator.component.view()).toBe('map'); + expect(set_options).toHaveBeenCalledWith({ zones: ['level-2'] }); + }); + + it('should switch to map view and fall back to the first level for an invalid zone', () => { + options.set({ zones: ['not-a-level'] }); + spectator.component.setMapView(); + expect(spectator.component.view()).toBe('map'); + expect(set_options).toHaveBeenCalledWith({ zones: ['level-1'] }); + }); + + it('should replace the selection with a single space by default', () => { + settings_get.mockReturnValue(false); + model.set({ resources: [{ id: 'space-9' }] }); + spectator.component.filters_open.set(true); + + spectator.component.toggleSpace({ id: 'space-1' } as any); + + expect(model().resources).toEqual([{ id: 'space-1' }]); + expect(spectator.component.filters_open()).toBe(false); + }); + + it('should append and toggle spaces when multiple selection is allowed', () => { + settings_get.mockImplementation( + (k: string) => k === 'app.events.allow_multiple_spaces', + ); + model.set({ resources: [{ id: 'space-1' }] }); + + spectator.component.toggleSpace({ id: 'space-2' } as any); + expect(model().resources).toEqual([ + { id: 'space-1' }, + { id: 'space-2' }, + ]); + + spectator.component.toggleSpace({ id: 'space-1' } as any); + expect(model().resources).toEqual([{ id: 'space-2' }]); + }); + + it('should allow multiple spaces with the current multiple spaces setting', () => { + settings_get.mockImplementation( + (k: string) => k === 'app.events.multiple_spaces', + ); + model.set({ resources: [{ id: 'space-1' }] }); + + spectator.component.toggleSpace({ id: 'space-2' } as any); + + expect(model().resources).toEqual([ + { id: 'space-1' }, + { id: 'space-2' }, + ]); + }); + + it('should filter rooms with the minimum and the maximum of the selected size', () => { + spectator.component.setCapacity(5); + expect(set_filters).toHaveBeenLastCalledWith({ + capacity: 5, + capacity_max: 8, + }); + + spectator.component.setCapacity(9); + expect(set_filters).toHaveBeenLastCalledWith({ + capacity: 9, + capacity_max: undefined, + }); + }); + + it('should block continuing without a selected space', () => { + model.set({ resources: [] }); + spectator.component.continue(); + expect(snackbar.open).toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('should navigate to the confirm step once a space is selected', () => { + model.set({ resources: [{ id: 'space-1' }] }); + spectator.component.continue(); + expect(snackbar.open).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith( + [], + expect.objectContaining({ queryParams: { view: 2 } }), + ); + }); + + it('should assign the active building and region through setters', () => { + const building_spy = vi.spyOn(org, 'building', 'set'); + const region_spy = vi.spyOn(org, 'region', 'set'); + spectator.component.setBuilding({ id: 'bld-2' }); + spectator.component.setRegion({ id: 'reg-1' }); + expect(building_spy).toHaveBeenCalledWith({ id: 'bld-2' }); + expect(region_spy).toHaveBeenCalledWith({ id: 'reg-1' }); + }); +}); diff --git a/apps/workplace/src/tests/book/meeting-flow-new/meeting-flow.component.spec.ts b/apps/workplace/src/tests/book/meeting-flow-new/meeting-flow.component.spec.ts new file mode 100644 index 0000000000..4a0e2a92ce --- /dev/null +++ b/apps/workplace/src/tests/book/meeting-flow-new/meeting-flow.component.spec.ts @@ -0,0 +1,136 @@ +import { signal } from '@angular/core'; +import { Router } from '@angular/router'; +import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/vitest'; +import { setNotifyOutlet } from '@placeos/common'; +import { mockComponent } from '@placeos/common/tests'; +import { EventFormService } from '@placeos/events'; +import { MeetingFlowNewComponent } from '../../../app/book/meeting-flow-new/meeting-flow.component'; +import { MeetingFlowDetailsComponent } from '../../../app/book/meeting-flow-new/meeting-flow-details.component'; +import { MeetingFlowOptionsComponent } from '../../../app/book/meeting-flow-new/meeting-flow-options.component'; +import { MeetingFlowSpaceSelectComponent } from '../../../app/book/meeting-flow-new/meeting-flow-space-select.component'; +import { MeetingFlowSuccessComponent } from '../../../app/book/meeting-flow/meeting-flow-success.component'; + +describe('MeetingFlowNewComponent', () => { + let spectator: SpectatorRouting; + let flow_view: ReturnType>; + let model: ReturnType>; + const snackbar = { + open: vi.fn(() => ({ + dismiss: vi.fn(), + onAction: () => ({ subscribe: vi.fn() }), + })), + }; + + const createComponent = createRoutingFactory({ + component: MeetingFlowNewComponent, + declarations: [ + mockComponent(MeetingFlowDetailsComponent), + mockComponent(MeetingFlowOptionsComponent), + mockComponent(MeetingFlowSpaceSelectComponent), + mockComponent(MeetingFlowSuccessComponent), + ], + providers: [ + { + provide: EventFormService, + useFactory: () => ({ + view: flow_view, + model, + loadForm: vi.fn(), + setView: vi.fn(), + }), + }, + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + setNotifyOutlet(snackbar as any, true); + flow_view = signal('form'); + model = signal({ title: 'Weekly Sync', resources: [] }); + spectator = createComponent(); + vi.spyOn(spectator.inject(Router), 'navigate').mockResolvedValue( + true, + ); + }); + + it('should render the stepper while the flow is not complete', () => { + expect(spectator.query('meeting-flow-success')).not.toExist(); + expect(spectator.query('meeting-flow-details')).toExist(); + }); + + it('should render the success view once the flow completes', () => { + flow_view.set('success'); + spectator.detectChanges(); + expect(spectator.query('meeting-flow-success')).toExist(); + expect(spectator.query('meeting-flow-details')).not.toExist(); + }); + + it('should swap the active step based on the view signal', () => { + spectator.component.view.set(1); + spectator.detectChanges(); + expect(spectator.query('meeting-flow-space-select')).toExist(); + expect(spectator.query('meeting-flow-details')).not.toExist(); + + spectator.component.view.set(2); + spectator.detectChanges(); + expect(spectator.query('meeting-flow-options')).toExist(); + expect(spectator.query('meeting-flow-space-select')).not.toExist(); + }); + + it('should load the form on init', () => { + expect(spectator.inject(EventFormService).loadForm).toHaveBeenCalled(); + }); + + it('should block navigating to room selection without a title', () => { + model.set({ title: ' ', resources: [] }); + spectator.component.navigateToView(1); + expect(snackbar.open).toHaveBeenCalled(); + expect(spectator.inject(Router).navigate).not.toHaveBeenCalled(); + }); + + it('should allow navigating to room selection when a title is set', () => { + spectator.component.navigateToView(1); + expect(snackbar.open).not.toHaveBeenCalled(); + expect(spectator.inject(Router).navigate).toHaveBeenCalledWith( + [], + expect.objectContaining({ queryParams: { view: 1 } }), + ); + }); + + it('should block navigating to confirm without a selected space', () => { + spectator.component.navigateToView(2); + expect(snackbar.open).toHaveBeenCalled(); + expect(spectator.inject(Router).navigate).not.toHaveBeenCalled(); + }); + + it('should allow navigating to confirm once a space is selected', () => { + model.set({ title: 'Sync', resources: [{ id: 'space-1' }] }); + spectator.component.navigateToView(2); + expect(snackbar.open).not.toHaveBeenCalled(); + expect(spectator.inject(Router).navigate).toHaveBeenCalledWith( + [], + expect.objectContaining({ queryParams: { view: 2 } }), + ); + }); + + it('should step forwards and clamp backwards navigation at zero', () => { + spectator.component.next(); + expect(spectator.component.view()).toBe(1); + spectator.component.previous(); + expect(spectator.component.view()).toBe(0); + spectator.component.previous(); + expect(spectator.component.view()).toBe(0); + }); + + it('should sync the active step from the view query param', () => { + spectator.setRouteQueryParam('view', '2'); + expect(spectator.component.view()).toBe(2); + }); + + it('should forward the step route param to the form service', () => { + spectator.setRouteParam('step', 'confirm'); + expect(spectator.inject(EventFormService).setView).toHaveBeenCalledWith( + 'confirm', + ); + }); +}); diff --git a/apps/workplace/src/tests/book/meeting-flow/meeting-flow-details.component.spec.ts b/apps/workplace/src/tests/book/meeting-flow/meeting-flow-details.component.spec.ts new file mode 100644 index 0000000000..96d5e73b46 --- /dev/null +++ b/apps/workplace/src/tests/book/meeting-flow/meeting-flow-details.component.spec.ts @@ -0,0 +1,140 @@ +import { Injector, signal, WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + createRoutingFactory, + SpectatorRouting, +} from '@ngneat/spectator/vitest'; +import { + CalendarEvent, + OrganisationService, + settingSignal, + SettingsService, +} from '@placeos/common'; +import { EventFormService } from '@placeos/events'; +import { MeetingFlowDetailsComponent } from 'apps/workplace/src/app/book/meeting-flow-new/meeting-flow-details.component'; +import { addDays, endOfDay } from 'date-fns'; +import { + EventFormValue, + generateEventForm, +} from 'libs/events/src/lib/event-form'; +import { MockProvider } from 'ng-mocks'; + +describe('MeetingFlowDetailsComponent', () => { + let spectator: SpectatorRouting; + let model: WritableSignal; + let event: CalendarEvent; + const createComponent = createRoutingFactory({ + component: MeetingFlowDetailsComponent, + shallow: true, + providers: [ + MockProvider( + SettingsService as any, + { + get: vi.fn(() => false), + } as any, + ), + MockProvider( + OrganisationService as any, + { + building: { timezone: '' }, + } as any, + ), + { + provide: EventFormService, + useFactory: () => { + // Build a real signal-forms event form, mirroring the + // production wiring of `EventFormService` + + // `generateEventForm`, so the component can read + // `form.date().disabled()` and `model()`. + const injector = TestBed.inject(Injector); + const { model: m, form } = TestBed.runInInjectionContext( + () => generateEventForm(event, undefined, injector), + ); + model = m; + return { + form, + model, + filters: signal({ capacity: -1 }), + setFilters: vi.fn(), + }; + }, + }, + ], + }); + + beforeEach(() => { + // Default: a fresh editable event. + event = new CalendarEvent({ + id: '', + title: 'Weekly sync', + date: Date.now(), + duration: 60, + }); + }); + + it('should disable the start time when editing an in-progress meeting', () => { + // A saved (has id) event with a start time in the recent past is + // `started`/`in_progress`, which the form schema locks the start time + // for while keeping the duration editable. + event = new CalendarEvent({ + id: 'event-1', + title: 'Weekly sync', + date: Date.now() - 5 * 60 * 1000, + duration: 60, + }); + spectator = createComponent(); + const original_date = spectator.component.form_value().date; + const original_duration = spectator.component.form_value().duration; + spectator.detectChanges(); + + expect(spectator.component.start_time_disabled()).toBe(true); + expect(spectator.component.form_value().date).toBe(original_date); + expect(spectator.component.form_value().duration).toBe( + original_duration, + ); + expect(spectator.component.form.duration().disabled()).toBe(false); + }); + + it('should keep the start time enabled for editable meetings', () => { + spectator = createComponent(); + expect(spectator.component.start_time_disabled()).toBe(false); + }); + + it('should filter rooms with the minimum and the maximum of the selected size', () => { + spectator = createComponent(); + const { setFilters } = spectator.inject(EventFormService); + + spectator.component.setCapacity(1); + expect(setFilters).toHaveBeenLastCalledWith({ + capacity: 1, + capacity_max: 2, + }); + + spectator.component.setCapacity(3); + expect(setFilters).toHaveBeenLastCalledWith({ + capacity: 3, + capacity_max: 4, + }); + + spectator.component.setCapacity(9); + expect(setFilters).toHaveBeenLastCalledWith({ + capacity: 9, + capacity_max: undefined, + }); + + spectator.component.setCapacity(-1); + expect(setFilters).toHaveBeenLastCalledWith({ + capacity: -1, + capacity_max: undefined, + }); + }); + + it('should limit the date to the allowed future days setting', () => { + spectator = createComponent(); + settingSignal('events.allowed_future_days').set(30); + + expect(spectator.component.end_date()).toBe( + endOfDay(addDays(Date.now(), 30)).valueOf(), + ); + }); +}); diff --git a/apps/workplace/src/tests/book/meeting-flow/meeting-flow-success.component.spec.ts b/apps/workplace/src/tests/book/meeting-flow/meeting-flow-success.component.spec.ts index 9a4cadc707..7da40dfaa7 100644 --- a/apps/workplace/src/tests/book/meeting-flow/meeting-flow-success.component.spec.ts +++ b/apps/workplace/src/tests/book/meeting-flow/meeting-flow-success.component.spec.ts @@ -88,7 +88,7 @@ describe('MeetingFlowSuccessComponent', () => { }); it("should show the resolved space's level", () => { - expect(spectator.component.level).toBe(level_2); + expect(spectator.component.level()).toBe(level_2); }); it('should show the recurring booking end date', async () => { diff --git a/apps/workplace/src/tests/book/parking-request-flow.component.spec.ts b/apps/workplace/src/tests/book/parking-request-flow.component.spec.ts new file mode 100644 index 0000000000..66c95b612a --- /dev/null +++ b/apps/workplace/src/tests/book/parking-request-flow.component.spec.ts @@ -0,0 +1,130 @@ +import { signal, WritableSignal } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { BookingFormService, ParkingService } from '@placeos/bookings'; +import { MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; + +import { ParkingRequestFlowComponent } from '../../app/book/parking-request-flow.component'; + +describe('ParkingRequestFlowComponent', () => { + let spectator: Spectator; + let model: WritableSignal; + let view: WritableSignal; + let is_home_location: WritableSignal; + let load_bookings: any; + let load_form: any; + let new_form: any; + let set_view: any; + let set_options: any; + let param_map: any; + let query_param_map: any; + + const createComponent = createComponentFactory({ + component: ParkingRequestFlowComponent, + detectChanges: false, + shallow: true, + providers: [ + MockProvider(BookingFormService, {} as any), + MockProvider(ParkingService, {} as any), + MockProvider(ActivatedRoute, {} as any), + ], + }); + + function build() { + spectator = createComponent({ + providers: [ + MockProvider(BookingFormService, { + model, + view, + loadForm: load_form, + newForm: new_form, + setView: set_view, + setOptions: set_options, + } as any), + MockProvider(ParkingService, { + is_home_location, + loadBookings: load_bookings, + } as any), + MockProvider(ActivatedRoute, { + paramMap: of(param_map), + queryParamMap: of(query_param_map), + } as any), + ], + }); + } + + beforeEach(() => { + vi.clearAllMocks(); + model = signal({ id: '', booking_type: '' }); + view = signal('form'); + is_home_location = signal(false); + load_bookings = vi.fn(); + load_form = vi.fn(); + new_form = vi.fn(); + set_view = vi.fn(); + set_options = vi.fn(); + param_map = { has: () => false, get: () => null }; + query_param_map = { has: () => false, get: () => null }; + }); + + it('should load parking bookings and initialise a parking form on init', () => { + build(); + spectator.component.ngOnInit(); + + expect(load_bookings).toHaveBeenCalled(); + expect(load_form).toHaveBeenCalledWith('parking'); + expect(set_options).toHaveBeenCalledWith({ type: 'parking' }); + expect(model().booking_type).toBe('parking'); + }); + + it('should start a new parking form when there is no active booking', () => { + build(); + spectator.component.ngOnInit(); + + expect(new_form).toHaveBeenCalledWith('parking'); + }); + + it('should not start a new form when an existing booking is loaded', () => { + model.set({ id: 'existing-1', booking_type: '' }); + build(); + spectator.component.ngOnInit(); + + expect(new_form).not.toHaveBeenCalled(); + }); + + it('should move to the view named by the route step parameter', () => { + param_map = { + has: (key: string) => key === 'step', + get: (key: string) => (key === 'step' ? 'confirm' : null), + }; + build(); + spectator.component.ngOnInit(); + + expect(set_view).toHaveBeenCalledWith('confirm'); + }); + + it('should move to the success view when the success query param is present', () => { + query_param_map = { + has: (key: string) => key === 'success', + get: () => 'true', + }; + build(); + spectator.component.ngOnInit(); + + expect(set_view).toHaveBeenCalledWith('success'); + }); + + it('should expose the home-location restriction and current view from the services', () => { + build(); + + expect(spectator.component.is_home_location()).toBe(false); + expect(spectator.component.view()).toBe('form'); + + is_home_location.set(true); + view.set('success'); + + expect(spectator.component.is_home_location()).toBe(true); + expect(spectator.component.view()).toBe('success'); + }); +}); diff --git a/apps/workplace/src/tests/book/parking-request-flow/parking-request-special-needs.spec.ts b/apps/workplace/src/tests/book/parking-request-flow/parking-request-special-needs.spec.ts new file mode 100644 index 0000000000..c30d2eef8c --- /dev/null +++ b/apps/workplace/src/tests/book/parking-request-flow/parking-request-special-needs.spec.ts @@ -0,0 +1,59 @@ +import { Injector, signal } from '@angular/core'; +import { ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { generateBookingForm, ParkingService } from '@placeos/bookings'; +import { OrganisationService, SettingsService } from '@placeos/common'; +import { MockProvider } from 'ng-mocks'; +import { ParkingRequestFormDetailsComponent } from '../../../app/book/parking-request-flow/parking-request-form-details.component'; + +vi.mock('@placeos/ts-client', { spy: true }); + +// Renders the real template. The main spec for this component uses an empty +// template, so it cannot find a field that is not bound to the form. +describe('ParkingRequestFormDetailsComponent special needs', () => { + let spectator: Spectator; + const createComponent = createComponentFactory({ + component: ParkingRequestFormDetailsComponent, + detectChanges: false, + providers: [ + { provide: ComponentFixtureAutoDetect, useValue: false }, + MockProvider(ParkingService, { spaces: signal([]) }), + MockProvider(SettingsService, { + get: vi.fn(), + saveUserSetting: vi.fn(), + time_format: 'h:mm a', + }), + MockProvider(OrganisationService, { + initialised: signal(true), + active_building: signal({ id: 'bld-1', timezone: 'UTC' }), + active_buildings: signal([]), + level_list: signal([]), + building: { id: 'bld-1', timezone: 'UTC' }, + } as never), + ], + }); + + it('should save the reason that the user types for a special needs request', () => { + spectator = createComponent(); + const { model, form } = TestBed.runInInjectionContext(() => + generateBookingForm(undefined, spectator.inject(Injector)), + ); + model.update((m) => ({ + ...m, + booking_type: 'parking', + request_type: 'special', + notes: '', + })); + spectator.fixture.componentRef.setInput('form', form); + spectator.fixture.componentRef.setInput('model_input', model); + spectator.fixture.componentRef.setInput('show_special_needs', true); + spectator.component.selected_request_type_id.set('special'); + spectator.detectChanges(); + + const reason = + spectator.query('textarea[rows="6"]'); + spectator.typeInElement('Mobility aid', reason); + + expect(model().notes).toBe('Mobility aid'); + }); +}); diff --git a/apps/workplace/src/tests/book/parking-request-form-details.component.spec.ts b/apps/workplace/src/tests/book/parking-request-form-details.component.spec.ts index f6fb19db37..0ba9bcc638 100644 --- a/apps/workplace/src/tests/book/parking-request-form-details.component.spec.ts +++ b/apps/workplace/src/tests/book/parking-request-form-details.component.spec.ts @@ -939,9 +939,14 @@ describe('ParkingRequestFormDetailsComponent', () => { }); it('should require the plate number when configured', () => { - spectator.component.model.update((m) => ({ ...m, plate_number: ' ' })); - spectator.component.require_plate_number.set(true); + attachForm({ + date: new Date('2026-04-08T08:00:00.000Z').valueOf(), + duration: 240, + booking_type: 'parking', + plate_number: '', + }); + spectator.detectChanges(); TestBed.flushEffects(); const errors = spectator.component.form().plate_number().errors(); diff --git a/apps/workplace/src/tests/book/parking-request-form.component.spec.ts b/apps/workplace/src/tests/book/parking-request-form.component.spec.ts index 8e657ffed1..d6d91f75e4 100644 --- a/apps/workplace/src/tests/book/parking-request-form.component.spec.ts +++ b/apps/workplace/src/tests/book/parking-request-form.component.spec.ts @@ -5,7 +5,11 @@ import { By } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { BookingFormService, ParkingService } from '@placeos/bookings'; -import { OrganisationService, SettingsService } from '@placeos/common'; +import { + OrganisationService, + settingSignal, + SettingsService, +} from '@placeos/common'; import { MockProvider } from 'ng-mocks'; import { ParkingRequestFormDetailsComponent } from '../../app/book/parking-request-flow/parking-request-form-details.component'; import { ParkingRequestFormComponent } from '../../app/book/parking-request-flow/parking-request-form.component'; @@ -88,6 +92,50 @@ describe('ParkingRequestFormComponent', () => { settings = spectator.inject(SettingsService); vi.mocked(settings.get).mockReset(); vi.mocked(settings.saveUserSetting).mockReset(); + settingSignal('parking.show_special_needs').set(false); + }); + + describe('special needs request', () => { + beforeEach(() => + model.update((value) => ({ ...value, request_type: 'special' })), + ); + + it('should not submit without a reason when the special needs details show', async () => { + settingSignal('parking.show_special_needs').set(true); + + await spectator.component.submitRequest(); + + expect(post_form).not.toHaveBeenCalled(); + }); + + it('should submit with the reason that the user entered', async () => { + settingSignal('parking.show_special_needs').set(true); + model.update((value) => ({ ...value, notes: 'Mobility aid' })); + + await spectator.component.submitRequest(); + + expect(post_form).toHaveBeenCalled(); + }); + + it('should show the details to a user with the special needs flag', async () => { + const parking = spectator.inject(ParkingService); + (parking.user_details as any).set({ + email: 'me@example.com', + special_needs: true, + }); + + await spectator.component.ngOnInit(); + + expect(spectator.component.show_special_needs()).toBe(true); + (parking.user_details as any).set(null); + }); + + it('should not require a reason when the special needs details do not show', async () => { + await spectator.component.submitRequest(); + + expect(spectator.component.show_special_needs()).toBe(false); + expect(post_form).toHaveBeenCalled(); + }); }); it('should set the parking request location from the selected building before submitting', async () => { diff --git a/apps/workplace/src/tests/book/visitor-flow-details.component.spec.ts b/apps/workplace/src/tests/book/visitor-flow-details.component.spec.ts new file mode 100644 index 0000000000..3c79d43014 --- /dev/null +++ b/apps/workplace/src/tests/book/visitor-flow-details.component.spec.ts @@ -0,0 +1,224 @@ +import { Injector, signal, WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + createRoutingFactory, + SpectatorRouting, +} from '@ngneat/spectator/vitest'; +import { + BookingForm, + BookingFormService, + BookingFormValue, + generateBookingForm, +} from '@placeos/bookings'; +import { + OrganisationService, + settingSignal, + SettingsService, +} from '@placeos/common'; +import { VisitorFlowDetailsComponent } from 'apps/workplace/src/app/book/visitor-flow-new/visitor-flow-details.component'; +import { MockProvider } from 'ng-mocks'; + +describe('VisitorFlowDetailsComponent', () => { + let spectator: SpectatorRouting; + let options: WritableSignal; + let form: BookingForm; + let model: WritableSignal; + let now = Date.now(); + const createComponent = createRoutingFactory({ + component: VisitorFlowDetailsComponent, + shallow: true, + providers: [ + MockProvider(SettingsService, { + get: vi.fn(), + }), + { + provide: OrganisationService, + useFactory: () => { + const building_list = [ + { id: 'bld-1', name: 'Building One', timezone: '' }, + { id: 'bld-2', name: 'Building Two', timezone: '' }, + ]; + return { + active_buildings: signal(building_list), + buildings: building_list, + building: building_list[0], + find: vi.fn((id: string) => + building_list.find( + (building) => building.id === id, + ), + ), + setBuilding: vi.fn(), + levelWithID: vi.fn((ids: string[]) => + ids?.includes('lvl-2') + ? { + id: 'lvl-2', + parent_id: 'bld-2', + name: 'Level Two', + } + : null, + ), + } as any; + }, + }, + { + provide: BookingFormService, + useFactory: () => { + options = signal({ + type: 'visitor', + group: false, + }); + // Build a real signal-forms booking form so the component + // can read `form.date`/`form.all_day` and `model()`. + const injector = TestBed.inject(Injector); + const refs = TestBed.runInInjectionContext(() => + generateBookingForm(undefined, injector), + ); + form = refs.form; + model = refs.model; + model.update((m) => ({ + ...m, + id: '', + asset_id: '', + asset_name: '', + company: '', + phone: '', + user: null, + user_email: '', + zones: [], + date: Date.now(), + duration: 60, + title: 'Visit', + all_day: false, + assets: [], + })); + return { + form, + model, + options, + setOptions: vi.fn((opts) => + options.set({ ...options(), ...opts }), + ), + }; + }, + }, + ], + }); + + beforeEach(() => { + settingSignal('visitors.duration_step').set(undefined); + settingSignal('visitors.min_duration').set(undefined); + settingSignal('visitors.max_duration').set(undefined); + settingSignal('visitors.custom_duration_options').set(undefined); + settingSignal('bookings.duration_step').set(15); + settingSignal('bookings.min_duration').set(30); + settingSignal('bookings.max_duration').set(180); + settingSignal('bookings.custom_duration_options').set([]); + now = Date.now(); + spectator = createComponent(); + }); + + it('should honour visitor duration overrides including a 45 minute exception', () => { + settingSignal('visitors.duration_step').set(30); + settingSignal('visitors.min_duration').set(60); + settingSignal('visitors.max_duration').set(45); + settingSignal('visitors.custom_duration_options').set([45]); + settingSignal('bookings.max_duration').set(30); + + expect(spectator.component.duration_step()).toBe(30); + expect(spectator.component.min_duration()).toBe(60); + expect(spectator.component.max_duration()).toBe(45); + expect(spectator.component.custom_duration_options()).toEqual([45]); + expect(spectator.component.effective_min_duration()).toBe(45); + }); + + it('should hide visitor type switching when editing an existing booking', () => { + const service = spectator.inject(BookingFormService); + model.update((m) => ({ + ...m, + id: 'visitor-booking-1', + asset_id: 'visitor.one@example.com', + asset_name: 'Visitor One', + })); + + spectator.component.ngOnInit(); + spectator.detectChanges(); + (service.setOptions as any).mockClear(); + spectator.component.setActiveForm('group'); + + expect(spectator.component.is_edit()).toBe(true); + expect(spectator.component.active_form()).toBe('single'); + expect(service.setOptions).not.toHaveBeenCalled(); + expect(spectator.element).not.toHaveText('BOOKINGS.VISITOR_SINGLE'); + expect(spectator.element).not.toHaveText('BOOKINGS.VISITOR_MULTIPLE'); + }); + + it('should disable date and time fields but keep duration editable when editing an in-progress booking', () => { + model.update((m) => ({ + ...m, + id: 'visitor-booking-2', + date: now - 5 * 60 * 1000, + duration: 60, + })); + + spectator.component.ngOnInit(); + spectator.detectChanges(); + + expect(spectator.component.is_edit_in_progress()).toBe(true); + expect(form.date().disabled()).toBe(true); + expect(form.duration().disabled()).toBe(false); + }); + + it('should show time fields and clear all day when all day is not allowed', () => { + model.update((m) => ({ ...m, all_day: true })); + + spectator.component.ngOnInit(); + spectator.detectChanges(); + + expect(spectator.component.is_all_day()).toBe(false); + expect(model().all_day).toBe(false); + expect(spectator.query('a-time-field')).toExist(); + expect(spectator.query('a-duration-field')).toExist(); + }); + + it('should make the selected building active so saves use its zones', () => { + const org = spectator.inject(OrganisationService); + model.update((m) => ({ + ...m, + id: 'visitor-booking-4', + zones: ['org-1', 'bld-1'], + })); + spectator.component.ngOnInit(); + + spectator.component.setBuilding('bld-2'); + + expect(org.building.id).toBe('bld-2'); + expect(model().zones).toEqual(['bld-2']); + expect(spectator.component.selected_building_id()).toBe('bld-2'); + }); + + it('should make the edited booking building active on load', () => { + const org = spectator.inject(OrganisationService); + model.update((m) => ({ + ...m, + id: 'visitor-booking-5', + zones: ['org-1', 'bld-2'], + })); + + spectator.component.ngOnInit(); + + expect(org.building.id).toBe('bld-2'); + }); + + it('should resolve the selected building from a level zone when editing', () => { + model.update((m) => ({ + ...m, + id: 'visitor-booking-3', + zones: ['org-1', 'lvl-2'], + })); + + spectator.component.ngOnInit(); + spectator.detectChanges(); + + expect(spectator.component.selected_building_id()).toBe('bld-2'); + }); +}); diff --git a/apps/workplace/src/tests/book/visitor-flow-new/recent-visitors.spec.ts b/apps/workplace/src/tests/book/visitor-flow-new/recent-visitors.spec.ts new file mode 100644 index 0000000000..4fa877cca6 --- /dev/null +++ b/apps/workplace/src/tests/book/visitor-flow-new/recent-visitors.spec.ts @@ -0,0 +1,69 @@ +import { + parseRecentVisitors, + recentVisitorEntry, +} from '../../../app/book/visitor-flow-new/recent-visitors'; + +describe('recent visitors', () => { + it('should read the international flag of the shared visitor forms and not use it as a phone number', () => { + const [local, international] = parseRecentVisitors([ + 'int@example.com|Int|Globex|1', + 'local@example.com|Local|Acme|0', + ]); + + expect(local).toEqual({ + email: 'local@example.com', + name: 'Local', + company: 'Acme', + phone: '', + international: false, + }); + expect(international.phone).toBe(''); + expect(international.international).toBe(true); + }); + + it('should read the phone number from the fifth field', () => { + const [visitor] = parseRecentVisitors([ + 'a@example.com|A|Acme|1|+61400111222', + ]); + + expect(visitor.phone).toBe('+61400111222'); + expect(visitor.international).toBe(true); + }); + + it('should read a phone number that an earlier version saved in the fourth field', () => { + const [visitor] = parseRecentVisitors([ + 'a@example.com|A|Acme|+61400111222', + ]); + + expect(visitor.phone).toBe('+61400111222'); + expect(visitor.international).toBe(false); + }); + + it('should keep the most recent item for each email address', () => { + const visitors = parseRecentVisitors([ + 'Alice@Example.com|Alice|Acme|0', + 'bob@example.com|Bob|Globex|0', + 'alice@example.com|Alice Again|Acme|0', + 42, + ]); + + expect(visitors.map((_) => _.name)).toEqual(['Alice Again', 'Bob']); + }); + + it('should write items that it can read back', () => { + const visitor = { + email: 'a@example.com', + name: 'A', + company: 'Acme', + phone: '+61400111222', + international: true, + }; + + expect(recentVisitorEntry(visitor)).toBe( + 'a@example.com|A|Acme|1|+61400111222', + ); + expect(parseRecentVisitors([recentVisitorEntry(visitor)])).toEqual([ + visitor, + ]); + }); +}); diff --git a/apps/workplace/src/tests/book/visitor-flow-new/visitor-flow-invites.component.spec.ts b/apps/workplace/src/tests/book/visitor-flow-new/visitor-flow-invites.component.spec.ts new file mode 100644 index 0000000000..c6c7a9ce87 --- /dev/null +++ b/apps/workplace/src/tests/book/visitor-flow-new/visitor-flow-invites.component.spec.ts @@ -0,0 +1,165 @@ +import { Injector, signal, WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { + BookingForm, + BookingFormValue, + BookingFormService, + generateBookingForm, +} from '@placeos/bookings'; +import { settingSignal, SettingsService } from '@placeos/common'; +import { mockComponent } from '@placeos/common/tests'; +import { UserListFieldComponent } from '@placeos/form-fields'; + +import { VisitorFlowInvitesComponent } from '../../../app/book/visitor-flow-new/visitor-flow-invites.component'; + +describe('VisitorFlowInvitesComponent', () => { + let spectator: Spectator; + let options: WritableSignal; + let form: BookingForm; + let model: WritableSignal; + let stored_visitors: string[]; + let save_user_setting: any; + + const createComponent = createComponentFactory({ + component: VisitorFlowInvitesComponent, + declarations: [mockComponent(UserListFieldComponent)], + providers: [ + { + provide: SettingsService, + useValue: { + get: vi.fn((key: string) => + key === 'visitor-invitees' ? stored_visitors : undefined, + ), + saveUserSetting: (...args: any[]) => + save_user_setting(...args), + }, + }, + { + provide: BookingFormService, + useFactory: () => { + options = signal({ type: 'visitor', group: false }); + const injector = TestBed.inject(Injector); + const refs = TestBed.runInInjectionContext(() => + generateBookingForm(undefined, injector), + ); + form = refs.form; + model = refs.model; + model.update((m) => ({ + ...m, + id: '', + title: '', + description: '', + asset_id: '', + asset_name: '', + company: '', + phone: '', + assets: [], + })); + return { form, model, options }; + }, + }, + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + stored_visitors = []; + save_user_setting = vi.fn(); + settingSignal('visitors.allow_pass_number', false).set(false); + settingSignal('bookings.multiple_visitors', false).set(false); + spectator = createComponent({ detectChanges: false }); + }); + + it('treats the form as single-visitor mode when not grouped', () => { + options.set({ type: 'visitor', group: false }); + expect(spectator.component.is_single()).toBe(true); + }); + + it('treats the form as multi-visitor mode when grouped', () => { + options.set({ type: 'visitor', group: true }); + expect(spectator.component.is_single()).toBe(false); + }); + + it('defaults the booking title and description on init when empty', () => { + spectator.component.ngOnInit(); + expect(model().title).toBe('Visit'); + expect(model().description).toBe('Visit'); + }); + + it('does not overwrite an existing booking title on init', () => { + model.update((m) => ({ ...m, id: 'booking-1', title: 'Client Visit' })); + spectator.component.ngOnInit(); + expect(model().title).toBe('Client Visit'); + }); + + it('seeds a placeholder asset id when initialising in group mode', () => { + options.set({ type: 'visitor', group: true }); + spectator.component.ngOnInit(); + expect(model().asset_id).toBe('multiple@place.tech'); + }); + + it('parses and de-duplicates recent visitors from settings on init', () => { + stored_visitors = [ + 'Alice@Place.Tech|Alice|Acme|+61400000001', + 'bob@place.tech|Bob|Globex|+61400000002', + 'alice@place.tech|Alice Again|Acme|+61400000003', + ]; + spectator.component.ngOnInit(); + const visitors = spectator.component.visitors(); + expect(visitors).toHaveLength(2); + const alice = visitors.find((v) => v.email === 'alice@place.tech'); + expect(alice).toBeTruthy(); + expect(alice.name).toBe('Alice Again'); + }); + + it('filters recent visitors by the current search term', () => { + stored_visitors = [ + 'alice@place.tech|Alice|Acme|', + 'bob@place.tech|Bob|Globex|', + ]; + spectator.component.ngOnInit(); + spectator.component.search_term.set('globex'); + expect(spectator.component.filtered_visitors()).toHaveLength(1); + expect(spectator.component.filtered_visitors()[0].email).toBe( + 'bob@place.tech', + ); + }); + + it('applies a selected visitor to the booking model', () => { + spectator.component.setVisitor({ + email: 'Carol@Place.Tech', + name: 'Carol', + company: 'Initech', + phone: '+61400111111', + }); + expect(model().asset_id).toBe('carol@place.tech'); + expect(model().asset_name).toBe('Carol'); + expect(model().company).toBe('Initech'); + expect(model().phone).toBe('+61400111111'); + }); + + it('records the selected visitor in the recent visitor history', () => { + spectator.component.setVisitor({ + email: 'carol@place.tech', + name: 'Carol', + company: 'Initech', + phone: '+61400111111', + }); + expect(save_user_setting).toHaveBeenCalledWith('visitor-invitees', [ + 'carol@place.tech|Carol|Initech|0|+61400111111', + ]); + }); + + it('ignores a selection with no resolvable email', () => { + spectator.component.setVisitor({ name: 'Nobody' }); + expect(model().asset_id).toBe(''); + expect(save_user_setting).not.toHaveBeenCalled(); + }); + + it('exposes the pass-number setting for template display', () => { + expect(spectator.component.allow_pass_number()).toBe(false); + settingSignal('visitors.allow_pass_number', false).set(true); + expect(spectator.component.allow_pass_number()).toBe(true); + }); +}); diff --git a/apps/workplace/src/tests/book/visitor-flow-new/visitor-flow-success.component.spec.ts b/apps/workplace/src/tests/book/visitor-flow-new/visitor-flow-success.component.spec.ts new file mode 100644 index 0000000000..d75aa184cd --- /dev/null +++ b/apps/workplace/src/tests/book/visitor-flow-new/visitor-flow-success.component.spec.ts @@ -0,0 +1,154 @@ +import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/vitest'; +import { Router } from '@angular/router'; +import { BookingFormService } from '@placeos/bookings'; +import { + Booking, + OrganisationService, + settingSignal, + SettingsService, +} from '@placeos/common'; +import { IconComponent } from '@placeos/components'; +import { MockComponent, MockProvider } from 'ng-mocks'; + +import { VisitorFlowSuccessComponent } from '../../../app/book/visitor-flow-new/visitor-flow-success.component'; + +describe('VisitorFlowSuccessComponent', () => { + let spectator: SpectatorRouting; + let clear_form: any; + const buildings = [ + { id: 'bld-1', name: 'HQ', display_name: 'Head Office' }, + { id: 'bld-2', name: 'Annex', display_name: 'The Annex' }, + ]; + + const makeBooking = () => + ({ + id: 'booking-1', + asset_id: 'visitor@external.com', + asset_name: 'Visitor One', + user_email: 'host@place.tech', + user_name: 'Host Person', + description: 'A visit', + attendees: [{ email: 'colleague@place.tech' }], + zones: ['bld-2'], + date: Date.parse('2026-07-04T10:00:00Z'), + }) as any as Booking; + + let last_success: Booking; + let last_count: number; + + const createComponent = createRoutingFactory({ + component: VisitorFlowSuccessComponent, + declarations: [MockComponent(IconComponent)], + providers: [ + MockProvider(SettingsService, { time_format: 'shortTime' } as any), + { + provide: OrganisationService, + useValue: { + waitUntilInitialised: vi.fn(() => Promise.resolve()), + building: buildings[0], + buildings, + }, + }, + { + provide: BookingFormService, + useFactory: () => ({ + get last_success() { + return last_success; + }, + get last_count() { + return last_count; + }, + clearForm: (...args: any[]) => clear_form(...args), + }), + }, + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + clear_form = vi.fn(); + last_success = makeBooking(); + last_count = 3; + settingSignal('bookings.multiple_visitors', false).set(false); + settingSignal('visitors.show_calendar_links', false).set(false); + spectator = createComponent({ detectChanges: false }); + }); + + it('loads the last successful booking and count on init', async () => { + await spectator.component.ngOnInit(); + expect(spectator.component.last_success()).toEqual(last_success); + expect(spectator.component.count()).toBe(3); + }); + + it('defaults the count to one when the form has no recorded count', async () => { + last_count = 0; + await spectator.component.ngOnInit(); + expect(spectator.component.count()).toBe(1); + }); + + it('resolves the building from the booking zones', async () => { + await spectator.component.ngOnInit(); + expect(spectator.component.building()).toEqual(buildings[1]); + }); + + it('falls back to the active building before a booking loads', () => { + expect(spectator.component.building()).toEqual(buildings[0]); + }); + + it('builds calendar links once a booking is loaded', async () => { + await spectator.component.ngOnInit(); + expect(spectator.component.cal_event()).toBeTruthy(); + expect(spectator.component.cal_event().attendees).toContain( + 'visitor@external.com', + ); + expect(spectator.component.outlook_link()).toContain('http'); + expect(spectator.component.google_link()).toContain('http'); + expect(spectator.component.ical_link()).toContain('data:text/calendar'); + }); + + it('produces no calendar links when no booking is loaded', () => { + expect(spectator.component.cal_event()).toBeNull(); + expect(spectator.component.outlook_link()).toBe(''); + expect(spectator.component.google_link()).toBe(''); + expect(spectator.component.ical_link()).toBe(''); + }); + + it('reports single-visitor mode by default', () => { + expect(spectator.component.multiple()).toBe(false); + }); + + it('reports multi-visitor mode when the setting is enabled', () => { + settingSignal('bookings.multiple_visitors', false).set(true); + expect(spectator.component.multiple()).toBe(true); + }); + + it('hides calendar links unless enabled', async () => { + await spectator.component.ngOnInit(); + spectator.detectChanges(); + expect(spectator.query('[name="visitor-outlook-link"]')).not.toExist(); + }); + + it('shows calendar links when enabled', async () => { + settingSignal('visitors.show_calendar_links', false).set(true); + await spectator.component.ngOnInit(); + spectator.detectChanges(); + expect(spectator.query('[name="visitor-outlook-link"]')).toExist(); + expect(spectator.query('[name="visitor-google-link"]')).toExist(); + expect(spectator.query('[name="visitor-ical-link"]')).toExist(); + }); + + it('navigates home when finishing', () => { + const router = spectator.inject(Router); + const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true); + spectator.component.done(); + expect(navigate).toHaveBeenCalledWith(['/']); + }); + + it('clears the form and restarts the flow when booking another', () => { + const router = spectator.inject(Router); + const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true); + spectator.component.bookAnother(); + expect(clear_form).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(['/book/visitor/form']); + }); +}); diff --git a/apps/workplace/src/tests/book/visitor-flow-recent.component.spec.ts b/apps/workplace/src/tests/book/visitor-flow-recent.component.spec.ts new file mode 100644 index 0000000000..6135768524 --- /dev/null +++ b/apps/workplace/src/tests/book/visitor-flow-recent.component.spec.ts @@ -0,0 +1,72 @@ +import { signal, WritableSignal } from '@angular/core'; +import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/vitest'; +import { BookingFormService } from '@placeos/bookings'; +import { SettingsService } from '@placeos/common'; +import { VisitorFlowRecentComponent } from 'apps/workplace/src/app/book/visitor-flow-new/visitor-flow-recent.component'; +import { MockProvider } from 'ng-mocks'; + +describe('VisitorFlowRecentComponent', () => { + let spectator: SpectatorRouting; + let model: WritableSignal; + let get_setting: any; + + const createComponent = createRoutingFactory({ + component: VisitorFlowRecentComponent, + shallow: true, + providers: [ + MockProvider( + SettingsService, + { + get: (...args: any[]) => get_setting(...args), + } as any, + ), + { + provide: BookingFormService, + useFactory: () => { + model = signal({ + id: '', + asset_id: '', + asset_name: '', + company: '', + phone: '', + assets: [], + }); + return { + model, + options: signal({ + type: 'visitor', + group: false, + }), + }; + }, + }, + ], + }); + + beforeEach(() => { + get_setting = vi.fn(() => []); + spectator = createComponent(); + }); + + it('should hide quick actions when editing an existing booking', () => { + model.update((m) => ({ ...m, id: 'visitor-booking-1' })); + + spectator.component.ngOnInit(); + spectator.detectChanges(); + + expect(spectator.component.is_edit()).toBe(true); + expect(spectator.element).not.toHaveText('Quick Action'); + expect(spectator.element).not.toHaveText('Recent Visitors'); + }); + + it('should restore visitor phone from saved recent visitors', () => { + get_setting.mockReturnValue([ + 'visitor.one@example.com|Visitor One|Acme|+61400111222', + ]); + + spectator.component.ngOnInit(); + spectator.component.selectVisitor(spectator.component.visitors()[0]); + + expect(model().phone).toBe('+61400111222'); + }); +}); diff --git a/apps/workplace/src/tests/book/visitor-flow.component.spec.ts b/apps/workplace/src/tests/book/visitor-flow.component.spec.ts index 490d9e1f8e..48b482d057 100644 --- a/apps/workplace/src/tests/book/visitor-flow.component.spec.ts +++ b/apps/workplace/src/tests/book/visitor-flow.component.spec.ts @@ -1,14 +1,290 @@ -import { Router } from '@angular/router'; +import { Injector, signal, WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { ActivatedRoute, Router } from '@angular/router'; import { createRoutingFactory, SpectatorRouting, } from '@ngneat/spectator/vitest'; -import { InviteVisitorFormComponent } from '@placeos/bookings'; +import { + BookingForm, + BookingFormService, + BookingFormValue, + generateBookingForm, + InviteVisitorFormComponent, +} from '@placeos/bookings'; +import { SettingsService } from '@placeos/common'; import { mockComponent } from '@placeos/common/tests'; import { MockProvider } from 'ng-mocks'; +import { NEVER, of } from 'rxjs'; +import { VisitorFlowNewComponent } from '../../app/book/visitor-flow-new/visitor-flow.component'; import { VisitorFlowComponent } from '../../app/book/visitor-flow.component'; +describe('VisitorFlowNewComponent', () => { + let spectator: SpectatorRouting; + let form: BookingForm; + let model: WritableSignal; + let save_user_setting: any; + let clear_form: any; + let dialog_open: any; + let dialog_close: any; + let new_form: any; + let options: WritableSignal; + let post_form: any; + let post_group: any; + + const createComponent = createRoutingFactory({ + component: VisitorFlowNewComponent, + shallow: true, + providers: [ + { + provide: BookingFormService, + useFactory: () => { + // Build a real signal-forms booking form so the component + // can read `form().valid()`/`form().dirty()` and `model()`. + const injector = TestBed.inject(Injector); + const refs = TestBed.runInInjectionContext(() => + generateBookingForm(undefined, injector), + ); + form = refs.form; + model = refs.model; + model.update((m) => ({ + ...m, + id: '', + title: 'Visit', + description: '', + asset_name: '', + asset_id: '', + company: '', + phone: '', + assets: [], + })); + options = signal({ type: 'visitor' }); + return { + form, + model, + options, + view: () => 'details', + booking: null, + last_count: 0, + postForm: (...args: any[]) => post_form(...args), + postFormForVisitorGroup: (...args: any[]) => + post_group(...args), + setOptions: vi.fn(), + setView: vi.fn(), + newForm: (...args: any[]) => new_form(...args), + clearForm: (...args: any[]) => clear_form(...args), + loadGroupSiblings: vi.fn(async () => []), + }; + }, + }, + { + provide: SettingsService, + useValue: { + get: vi.fn(() => []), + saveUserSetting: (...args: any[]) => + save_user_setting(...args), + }, + }, + { + provide: ActivatedRoute, + useValue: { + paramMap: of({ + has: () => false, + get: () => null, + }), + }, + } as any, + { + provide: MatDialog, + useValue: { + open: (...args: any[]) => dialog_open(...args), + }, + }, + ], + mocks: [], + }); + + beforeEach(() => { + save_user_setting = vi.fn(); + clear_form = vi.fn(); + post_form = vi.fn(() => Promise.resolve()); + post_group = vi.fn(() => Promise.resolve()); + dialog_close = vi.fn(); + dialog_open = vi.fn(() => ({ + componentInstance: { event: of({ reason: 'done' }) }, + afterClosed: () => NEVER, + close: dialog_close, + })); + new_form = vi.fn(() => + model.update((m) => ({ + ...m, + id: '', + booking_type: 'visitor', + asset_id: '', + asset_name: '', + assets: [], + })), + ); + spectator = createComponent({ detectChanges: false }); + }); + + it('should clear desk details when starting a visitor invite', () => { + model.update((m) => ({ + ...m, + booking_type: 'desk', + asset_id: 'desk-1', + asset_name: 'Desk 1', + })); + + spectator.component.ngOnInit(); + + expect(new_form).toHaveBeenCalledWith('visitor'); + expect(model().asset_id).toBe(''); + expect(model().asset_name).toBe(''); + }); + + it('should preserve visitor details when editing a visitor invite', () => { + model.update((m) => ({ + ...m, + id: 'visitor-booking-1', + booking_type: 'visitor', + asset_id: 'visitor@example.com', + asset_name: 'Visitor One', + })); + + spectator.component.ngOnInit(); + + expect(new_form).not.toHaveBeenCalled(); + expect(model().asset_id).toBe('visitor@example.com'); + expect(model().asset_name).toBe('Visitor One'); + }); + + it('should show visit details heading when creating a booking', () => { + expect(spectator.component.visit_heading()).toBe( + 'BOOKINGS.VISITOR_TIME_HEADER', + ); + }); + + it('should show edit visitor details heading in the visit details section when editing a booking', () => { + model.update((m) => ({ ...m, id: 'visitor-booking-3' })); + + expect(spectator.component.visit_heading()).toBe( + 'BOOKINGS.EDIT_VISITOR_DETAILS', + ); + }); + + it('should save visitor phone in recent visitors history', () => { + model.update((m) => ({ + ...m, + asset_id: 'visitor.one@example.com', + asset_name: 'Visitor One', + company: 'Acme', + phone: '+61400111222', + })); + + (spectator.component as any)._saveRecentVisitors(false); + + expect(save_user_setting).toHaveBeenCalledWith('visitor-invitees', [ + 'visitor.one@example.com|Visitor One|Acme|0|+61400111222', + ]); + }); + + describe('confirmBooking', () => { + const visit_time = () => ({ + booking_type: 'visitor' as const, + date: Date.now() + 60 * 60 * 1000, + duration: 60, + }); + + it('should save an invite for a single visitor', async () => { + model.update((m) => ({ + ...m, + ...visit_time(), + asset_id: 'visitor.one@example.com', + asset_name: 'Visitor One', + })); + + await spectator.component.confirmBooking(); + + expect(post_form).toHaveBeenCalled(); + expect(post_group).not.toHaveBeenCalled(); + expect(model().attendees.map((_) => _.email)).toEqual([ + 'visitor.one@example.com', + ]); + }); + + it('should not save a multiple visitor invite that has no visitors', async () => { + options.set({ type: 'visitor', group: true }); + model.update((m) => ({ + ...m, + ...visit_time(), + asset_id: 'multiple@place.tech', + assets: [], + })); + + await spectator.component.confirmBooking(); + + expect(post_form).not.toHaveBeenCalled(); + expect(post_group).not.toHaveBeenCalled(); + expect(save_user_setting).not.toHaveBeenCalled(); + }); + + it('should save a group invite for multiple visitors', async () => { + options.set({ type: 'visitor', group: true }); + model.update((m) => ({ + ...m, + ...visit_time(), + asset_id: 'multiple@place.tech', + assets: [ + { email: 'visitor.one@example.com', name: 'Visitor One' }, + { email: 'visitor.two@example.com', name: 'Visitor Two' }, + ], + })); + + await spectator.component.confirmBooking(); + + expect(post_group).toHaveBeenCalled(); + expect(post_form).not.toHaveBeenCalled(); + }); + }); + + it('should allow navigation without prompting when the edit form has no unsaved changes', async () => { + model.update((m) => ({ ...m, id: 'visitor-booking-3' })); + + await expect(spectator.component.canDeactivate()).resolves.toBe(true); + + expect(dialog_open).not.toHaveBeenCalled(); + expect(clear_form).not.toHaveBeenCalled(); + }); + + it('should discard dirty edit state after confirming navigation', async () => { + model.update((m) => ({ ...m, id: 'visitor-booking-3' })); + form().markAsDirty(); + + await expect(spectator.component.canDeactivate()).resolves.toBe(true); + + expect(dialog_open).toHaveBeenCalled(); + expect(dialog_close).toHaveBeenCalled(); + expect(clear_form).toHaveBeenCalled(); + }); + + it('should keep dirty edit state when navigation is cancelled', async () => { + dialog_open.mockReturnValue({ + componentInstance: { event: NEVER }, + afterClosed: () => of(null), + close: vi.fn(), + }); + model.update((m) => ({ ...m, id: 'visitor-booking-3' })); + form().markAsDirty(); + + await expect(spectator.component.canDeactivate()).resolves.toBe(false); + + expect(clear_form).not.toHaveBeenCalled(); + }); +}); + describe('VisitorFlowComponent', () => { let spectator: SpectatorRouting; const createComponent = createRoutingFactory({ diff --git a/apps/workplace/src/tests/components/footer-menu.component.spec.ts b/apps/workplace/src/tests/components/footer-menu.component.spec.ts index 6cfdb17c57..81dbc7e948 100644 --- a/apps/workplace/src/tests/components/footer-menu.component.spec.ts +++ b/apps/workplace/src/tests/components/footer-menu.component.spec.ts @@ -80,9 +80,9 @@ describe('FooterMenuComponent', () => { setSettings({ 'app.features': ['spaces', 'parking'] }); spectator.component.show_book_items.set(true); spectator.detectChanges(); - expect('[name="footer-nav-meeting"]').toExist(); + expect('[name="footer-nav-spaces"]').toExist(); expect('[name="footer-nav-parking"]').toExist(); - expect('[name="footer-nav-visitors"]').not.toExist(); + expect('[name="footer-nav-visitor-invite"]').not.toExist(); }); it('resolves dark mode from the allow_dark_mode setting and active theme', () => { diff --git a/apps/workplace/src/tests/components/fullscreen-embed.component.spec.ts b/apps/workplace/src/tests/components/fullscreen-embed.component.spec.ts new file mode 100644 index 0000000000..b841120035 --- /dev/null +++ b/apps/workplace/src/tests/components/fullscreen-embed.component.spec.ts @@ -0,0 +1,45 @@ +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { IconComponent, SafePipe } from '@placeos/components'; +import { MockComponent } from 'ng-mocks'; + +import { FullscreenEmbedComponent } from '../../app/components/fullscreen-embed.component'; + +describe('FullscreenEmbedComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: FullscreenEmbedComponent, + imports: [SafePipe], + declarations: [MockComponent(IconComponent)], + providers: [{ provide: MAT_DIALOG_DATA, useValue: '' }], + }); + + const create = (url: string) => + (spectator = createComponent({ + providers: [{ provide: MAT_DIALOG_DATA, useValue: url }], + })); + + it('exposes the injected url as the embed url', () => { + create('https://embed.example/portal'); + expect(spectator.component.embed_url()).toBe( + 'https://embed.example/portal', + ); + }); + + it('renders an iframe pointing at the embedded url', () => { + create('https://embed.example/portal'); + spectator.detectChanges(); + const iframe = spectator.query('iframe'); + expect(iframe).toExist(); + expect(iframe.getAttribute('src')).toBe( + 'https://embed.example/portal', + ); + }); + + it('renders a close button bound to the dialog', () => { + create('https://embed.example/portal'); + spectator.detectChanges(); + expect(spectator.query('button[mat-dialog-close]')).toExist(); + }); +}); diff --git a/apps/workplace/src/tests/components/sidebar-embed.component.spec.ts b/apps/workplace/src/tests/components/sidebar-embed.component.spec.ts new file mode 100644 index 0000000000..0b745365f3 --- /dev/null +++ b/apps/workplace/src/tests/components/sidebar-embed.component.spec.ts @@ -0,0 +1,57 @@ +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { IconComponent, SafePipe } from '@placeos/components'; +import { MockComponent } from 'ng-mocks'; + +import { SidebarEmbedComponent } from '../../app/components/sidebar-embed.component'; + +describe('SidebarEmbedComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: SidebarEmbedComponent, + imports: [SafePipe], + declarations: [MockComponent(IconComponent)], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: { url: '', side: 'left' } }, + ], + }); + + const create = (data: { url: string; side: 'left' | 'right' }) => + (spectator = createComponent({ + providers: [{ provide: MAT_DIALOG_DATA, useValue: data }], + })); + + it('exposes the injected url as the embed url', () => { + create({ url: 'https://embed.example/panel', side: 'right' }); + expect(spectator.component.embed_url()).toBe( + 'https://embed.example/panel', + ); + }); + + it('places the close button opposite the panel when docked right', () => { + create({ url: 'https://embed.example/panel', side: 'right' }); + expect(spectator.component.close_button_side()).toBe('left'); + spectator.detectChanges(); + const close = spectator.query('.sidebar-embed__close'); + expect(close).toHaveClass('sidebar-embed__close--left'); + expect(close).not.toHaveClass('sidebar-embed__close--right'); + }); + + it('places the close button opposite the panel when docked left', () => { + create({ url: 'https://embed.example/panel', side: 'left' }); + expect(spectator.component.close_button_side()).toBe('right'); + spectator.detectChanges(); + const close = spectator.query('.sidebar-embed__close'); + expect(close).toHaveClass('sidebar-embed__close--right'); + expect(close).not.toHaveClass('sidebar-embed__close--left'); + }); + + it('renders the iframe pointing at the embedded url', () => { + create({ url: 'https://embed.example/panel', side: 'left' }); + spectator.detectChanges(); + const iframe = spectator.query('iframe'); + expect(iframe).toExist(); + expect(iframe.getAttribute('src')).toBe('https://embed.example/panel'); + }); +}); diff --git a/apps/workplace/src/tests/components/topbar.component.spec.ts b/apps/workplace/src/tests/components/topbar.component.spec.ts index a535f65f03..c2166e5fa2 100644 --- a/apps/workplace/src/tests/components/topbar.component.spec.ts +++ b/apps/workplace/src/tests/components/topbar.component.spec.ts @@ -27,7 +27,11 @@ describe('TopbarComponent', () => { const createComponent = createRoutingFactory({ component: TopbarComponent, providers: [ - MockProvider(SettingsService, { get: vi.fn(), value: vi.fn() }), + MockProvider(SettingsService, { + get: vi.fn(), + value: vi.fn(), + theme_signal: signal(''), + }), MockProvider(OrganisationService, { active_building: signal(new Building()), active_region: signal(null), @@ -71,8 +75,8 @@ describe('TopbarComponent', () => { it('should render user avatar and details', () => expect('[avatar]').toExist()); - it('should render user controls sidebar', () => { - expect('user-controls-sidebar').toExist(); + it('should render user controls trigger', () => { expect('[name="user-controls"]').toExist(); + expect(spectator.component.user_controls).toBeTruthy(); }); }); diff --git a/apps/workplace/src/tests/components/virtual-concierge-button.component.spec.ts b/apps/workplace/src/tests/components/virtual-concierge-button.component.spec.ts new file mode 100644 index 0000000000..1b5cc969a3 --- /dev/null +++ b/apps/workplace/src/tests/components/virtual-concierge-button.component.spec.ts @@ -0,0 +1,125 @@ +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { MatDialog } from '@angular/material/dialog'; +import { settingSignal } from '@placeos/common'; +import { MockProvider } from 'ng-mocks'; + +import { VirtualConciergeButtonComponent } from '../../app/components/virtual-concierge-button.component'; +import { FullscreenEmbedComponent } from '../../app/components/fullscreen-embed.component'; +import { SidebarEmbedComponent } from '../../app/components/sidebar-embed.component'; + +describe('VirtualConciergeButtonComponent', () => { + let spectator: Spectator; + let open: any; + + const createComponent = createComponentFactory({ + component: VirtualConciergeButtonComponent, + providers: [MockProvider(MatDialog)], + }); + + const setUrl = (value: string) => + settingSignal('virtual_concierge.url', '').set(value); + const setLegacyUrl = (value: string) => + settingSignal('virtual_concierge_url', '').set(value); + const setIcon = (value: string) => + settingSignal('virtual_concierge.icon', '').set(value); + const setDisplay = (value: 'fullscreen' | 'sidebar') => + settingSignal('virtual_concierge.display', 'fullscreen').set(value); + const setSide = (value: 'left' | 'right') => + settingSignal('virtual_concierge.side', 'left').set(value); + + beforeEach(() => { + setUrl(''); + setLegacyUrl(''); + setIcon(''); + setDisplay('fullscreen'); + setSide('left'); + spectator = createComponent(); + open = vi.fn(); + (spectator.inject(MatDialog) as any).open = open; + }); + + it('hides the button when no virtual concierge url is configured', () => { + spectator.detectChanges(); + expect(spectator.query('button')).not.toExist(); + }); + + it('shows the button when a url is configured', () => { + setUrl('https://concierge.example'); + spectator.detectChanges(); + expect(spectator.query('button')).toExist(); + }); + + it('shows the configured image, or a default icon', () => { + setUrl('https://concierge.example'); + spectator.detectChanges(); + expect(spectator.query('button icon')).toExist(); + expect(spectator.query('button img')).not.toExist(); + + setIcon('assets/icons/concierge.png'); + spectator.detectChanges(); + expect(spectator.query('button img')).toHaveAttribute( + 'src', + 'assets/icons/concierge.png', + ); + expect(spectator.query('button icon')).not.toExist(); + }); + + it('falls back to the legacy url setting', () => { + setLegacyUrl('https://legacy.example'); + spectator.detectChanges(); + expect(spectator.component.virtual_concierge_url()).toBe( + 'https://legacy.example', + ); + expect(spectator.query('button')).toExist(); + }); + + it('opens a fullscreen embed dialog by default', () => { + setUrl('https://concierge.example'); + spectator.detectChanges(); + spectator.click('button'); + expect(open).toHaveBeenCalledWith(FullscreenEmbedComponent, { + data: 'https://concierge.example', + }); + }); + + it('opens a left-docked sidebar embed dialog when configured for sidebar display', () => { + setUrl('https://concierge.example'); + setDisplay('sidebar'); + setSide('left'); + spectator.detectChanges(); + spectator.click('button'); + + expect(open).toHaveBeenCalledWith( + SidebarEmbedComponent, + expect.objectContaining({ + data: { url: 'https://concierge.example', side: 'left' }, + width: '28rem', + position: { left: '0', top: '0' }, + panelClass: [ + 'sidebar-embed-dialog', + 'sidebar-embed-dialog-left', + ], + }), + ); + }); + + it('anchors the sidebar dialog to the right when docked right', () => { + setUrl('https://concierge.example'); + setDisplay('sidebar'); + setSide('right'); + spectator.detectChanges(); + spectator.click('button'); + + expect(open).toHaveBeenCalledWith( + SidebarEmbedComponent, + expect.objectContaining({ + data: { url: 'https://concierge.example', side: 'right' }, + position: { right: '0', top: '0' }, + panelClass: [ + 'sidebar-embed-dialog', + 'sidebar-embed-dialog-right', + ], + }), + ); + }); +}); diff --git a/apps/workplace/src/tests/explore/event-pin.component.spec.ts b/apps/workplace/src/tests/explore/event-pin.component.spec.ts new file mode 100644 index 0000000000..950de2f2d2 --- /dev/null +++ b/apps/workplace/src/tests/explore/event-pin.component.spec.ts @@ -0,0 +1,56 @@ +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { MAP_FEATURE_DATA } from '@placeos/common'; +import { IconComponent } from '@placeos/components'; +import { MockComponent } from 'ng-mocks'; + +import { EventPinComponent } from '../../app/explore/event-pin.component'; + +describe('EventPinComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: EventPinComponent, + declarations: [MockComponent(IconComponent)], + providers: [{ provide: MAP_FEATURE_DATA, useValue: {} }], + }); + + const create = (data: any) => + (spectator = createComponent({ + providers: [{ provide: MAP_FEATURE_DATA, useValue: data }], + })); + + it('falls back to theme defaults when no colours are provided', () => { + create({}); + expect(spectator.component.fill).toBe('var(--secondary)'); + expect(spectator.component.stroke).toBe('var(--secondary-focus)'); + expect(spectator.component.icon_color).toBe('var(--secondary-content)'); + }); + + it('uses the colours supplied via map feature data', () => { + create({ + fill: '#ff0000', + stroke: '#00ff00', + icon_color: '#0000ff', + }); + expect(spectator.component.fill).toBe('#ff0000'); + expect(spectator.component.stroke).toBe('#00ff00'); + expect(spectator.component.icon_color).toBe('#0000ff'); + }); + + it('renders the pin path with the resolved fill and stroke', () => { + create({ fill: '#123456', stroke: '#654321' }); + spectator.detectChanges(); + const path = spectator.query('path'); + expect(path).toExist(); + expect(path.getAttribute('fill')).toBe('#123456'); + expect(path.getAttribute('stroke')).toBe('#654321'); + }); + + it('renders the calendar icon coloured with the icon colour', () => { + create({ icon_color: '#abcdef' }); + spectator.detectChanges(); + const icon = spectator.query('icon'); + expect(icon).toExist(); + expect(icon.getAttribute('style')).toContain('rgb(171, 205, 239)'); + }); +}); diff --git a/apps/workplace/src/tests/explore/explore.component.spec.ts b/apps/workplace/src/tests/explore/explore.component.spec.ts new file mode 100644 index 0000000000..7ec92768ee --- /dev/null +++ b/apps/workplace/src/tests/explore/explore.component.spec.ts @@ -0,0 +1,58 @@ +import { signal } from '@angular/core'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { DesksService } from '@placeos/bookings'; +import { OrganisationService } from '@placeos/common'; +import { mockComponent } from '@placeos/common/tests'; +import { ExploreMapViewComponent, ExploreStateService } from '@placeos/explore'; +import { MockProvider } from 'ng-mocks'; +import { FooterMenuComponent } from '../../app/components/footer-menu.component'; +import { TopbarComponent } from '../../app/components/topbar.component'; +import { ExploreComponent } from '../../app/explore/explore.component'; +import { LandingStateService } from '../../app/landing/landing-state.service'; + +describe('ExploreComponent', () => { + let spectator: Spectator; + const upcoming_events = signal([]); + const createComponent = createComponentFactory({ + component: ExploreComponent, + providers: [ + { provide: DesksService, useValue: {} }, + MockProvider(LandingStateService, { + upcoming_events, + }), + MockProvider(ExploreStateService, {}), + MockProvider(OrganisationService, { levelWithID: vi.fn() }), + ], + declarations: [ + mockComponent(TopbarComponent), + mockComponent(FooterMenuComponent), + mockComponent(ExploreMapViewComponent), + ], + }); + + beforeEach(() => { + upcoming_events.set([]); + spectator = createComponent(); + }); + + it('should create component', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should place the upcoming booking pill below map controls on mobile', () => { + upcoming_events.set([ + { + title: 'Desk booking', + type: 'desk', + date: Date.now() + 60 * 60 * 1000, + date_end: Date.now() + 2 * 60 * 60 * 1000, + zones: ['lvl-1'], + }, + ]); + spectator.detectChanges(); + + const pill = spectator.query('[name="upcoming-booking-map-pill"]'); + expect(pill).toHaveClass('top-20'); + expect(pill).toHaveClass('sm:top-2'); + }); +}); diff --git a/apps/workplace/src/tests/landing/add-colleagues-modal.component.spec.ts b/apps/workplace/src/tests/landing/add-colleagues-modal.component.spec.ts new file mode 100644 index 0000000000..b729cf3fe0 --- /dev/null +++ b/apps/workplace/src/tests/landing/add-colleagues-modal.component.spec.ts @@ -0,0 +1,118 @@ +import { signal } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { MockProvider } from 'ng-mocks'; +import { setNotifyOutlet } from '@placeos/common'; +import { LandingStateService } from '../../app/landing/landing-state.service'; +import { AddColleaguesModalComponent } from '../../app/landing-new/add-colleagues-modal.component'; + +const user_a = { name: 'Alice', email: 'alice@example.com' }; +const user_b = { name: 'Bob', email: 'bob@example.com' }; + +describe('AddColleaguesModalComponent', () => { + let spectator: Spectator; + const search_results = signal([]); + const loading = signal(''); + const contacts = signal([]); + const state = { + search_results, + loading, + contacts, + setOptions: vi.fn(), + addContacts: vi.fn(() => Promise.resolve()), + }; + const dialog_ref = { close: vi.fn() }; + const snackbar = { + open: vi.fn(() => ({ + dismiss: vi.fn(), + onAction: () => ({ subscribe: vi.fn() }), + })), + }; + const createComponent = createComponentFactory({ + component: AddColleaguesModalComponent, + detectChanges: false, + providers: [ + MockProvider(LandingStateService, state as any), + { provide: MatDialogRef, useValue: dialog_ref }, + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + search_results.set([]); + loading.set(''); + contacts.set([]); + snackbar.open.mockClear(); + setNotifyOutlet(snackbar as any, true); + state.addContacts.mockResolvedValue(undefined); + spectator = createComponent(); + }); + + it('should reset the search filter on init', () => { + spectator.component.ngOnInit(); + expect(state.setOptions).toHaveBeenCalledWith({ search: '' }); + }); + + it('should push the search term into the shared state', () => { + spectator.component.search_term = 'ali'; + expect(spectator.component.search_term).toBe('ali'); + expect(state.setOptions).toHaveBeenCalledWith({ search: 'ali' }); + }); + + it('should expose loading and available users from state', () => { + loading.set('Loading users...'); + search_results.set([user_a]); + expect(spectator.component.loading_users()).toBe(true); + expect(spectator.component.available_users()).toEqual([user_a]); + loading.set(''); + expect(spectator.component.loading_users()).toBe(false); + }); + + it('should toggle users in and out of the selection', () => { + spectator.component.toggleUser(user_a as any); + expect(spectator.component.isUserSelected(user_a as any)).toBe(true); + spectator.component.toggleUser(user_b as any); + expect(spectator.component.selected_users().length).toBe(2); + spectator.component.toggleUser(user_a as any); + expect(spectator.component.isUserSelected(user_a as any)).toBe(false); + expect(spectator.component.selected_users()).toEqual([user_b]); + }); + + it('should recognise existing contacts', () => { + contacts.set([user_a]); + expect(spectator.component.isExistingContact(user_a as any)).toBe(true); + expect(spectator.component.isExistingContact(user_b as any)).toBe(false); + }); + + it('should add selected colleagues and close on confirm', async () => { + spectator.component.selected_users.set([user_a] as any); + await spectator.component.confirm(); + expect(state.addContacts).toHaveBeenCalledWith([user_a]); + expect(snackbar.open).toHaveBeenCalledWith( + 'APP.WORKPLACE.COLLEAGUES_ADD_SUCCESS', + 'OK', + expect.objectContaining({ panelClass: ['success'] }), + ); + expect(dialog_ref.close).toHaveBeenCalledWith(true); + expect(spectator.component.loading()).toBe(false); + }); + + it('should notify and stay open when adding fails', async () => { + state.addContacts.mockRejectedValueOnce(new Error('nope')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + spectator.component.selected_users.set([user_a] as any); + await spectator.component.confirm(); + expect(snackbar.open).toHaveBeenCalledWith( + 'APP.WORKPLACE.COLLEAGUES_ADD_ERROR', + 'OK', + expect.objectContaining({ panelClass: ['error'] }), + ); + expect(dialog_ref.close).not.toHaveBeenCalledWith(true); + expect(spectator.component.loading()).toBe(false); + }); + + it('should close with false when dismissed', () => { + spectator.component.dismiss(); + expect(dialog_ref.close).toHaveBeenCalledWith(false); + }); +}); diff --git a/apps/workplace/src/tests/landing/chat-view.component.spec.ts b/apps/workplace/src/tests/landing/chat-view.component.spec.ts new file mode 100644 index 0000000000..70512d42c5 --- /dev/null +++ b/apps/workplace/src/tests/landing/chat-view.component.spec.ts @@ -0,0 +1,55 @@ +import { MatBottomSheetRef } from '@angular/material/bottom-sheet'; +import { Component } from '@angular/core'; +import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/vitest'; +import { ChatViewComponent } from '../../app/landing-new/chat-view.component'; + +@Component({ template: '' }) +class BlankRouteComponent {} + +describe('ChatViewComponent', () => { + let spectator: SpectatorRouting; + const bottom_ref = { dismiss: vi.fn() }; + const createComponent = createRoutingFactory({ + component: ChatViewComponent, + stubsEnabled: false, + declarations: [BlankRouteComponent], + routes: [{ path: '**', component: BlankRouteComponent }], + providers: [{ provide: MatBottomSheetRef, useValue: bottom_ref }], + }); + + beforeEach(() => { + vi.clearAllMocks(); + spectator = createComponent(); + }); + + it('should dismiss the bottom sheet when closed', () => { + spectator.component.close(); + expect(bottom_ref.dismiss).toHaveBeenCalled(); + }); + + it('should greet the current user', () => { + (spectator.component.user as any).set({ name: 'Jane Doe' }); + spectator.detectChanges(); + expect(spectator.query('h2')?.textContent).toContain('Jane Doe'); + }); + + it('should close the sheet when a quick action link is clicked', () => { + spectator.detectChanges(); + const links = spectator.queryAll('a[btn]'); + expect(links.length).toBe(4); + spectator.click(links[0]); + expect(bottom_ref.dismiss).toHaveBeenCalled(); + }); + + it('should route quick actions to the booking flows', () => { + spectator.detectChanges(); + const links = spectator.queryAll('a[btn]'); + const hrefs = links.map((a) => a.getAttribute('href')); + expect(hrefs).toEqual([ + '/book/meeting', + '/book/desk', + '/your-bookings', + '/book/parking', + ]); + }); +}); diff --git a/apps/workplace/src/tests/landing/landing-available-now.component.spec.ts b/apps/workplace/src/tests/landing/landing-available-now.component.spec.ts new file mode 100644 index 0000000000..b47bf09486 --- /dev/null +++ b/apps/workplace/src/tests/landing/landing-available-now.component.spec.ts @@ -0,0 +1,206 @@ +import { signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/vitest'; +import { + OrganisationService, + setCurrentUser, + settingSignal, + SettingsService, + StaffUser, +} from '@placeos/common'; +import { PaymentsService } from '@placeos/payments'; +import { getUnixTime } from 'date-fns'; +import { AssetStateService } from 'libs/assets/src/lib/asset-state.service'; +import { CalendarService } from 'libs/events/src/lib/calendar.service'; +import { MockProvider } from 'ng-mocks'; + +vi.mock('@placeos/ts-client', { spy: true }); + +import * as ts_client from '@placeos/ts-client'; +import { LandingAvailableNowComponent } from '../../app/landing-new/landing-available-now.component'; +import { LandingStateService } from '../../app/landing/landing-state.service'; + +const LEVELS = [ + { id: 'lvl-1', parent_id: 'bld-1', name: 'Level 1' }, + { id: 'lvl-2', parent_id: 'bld-1', name: 'Level 2' }, +]; + +// The booking form service is real: only its dependencies and the requests +// to the API (`@placeos/ts-client`) are mocked. +describe('LandingAvailableNowComponent', () => { + let spectator: SpectatorRouting; + const features = settingSignal('features', []); + const hide_spaces = settingSignal('hide_landing_spaces'); + const hide_rooms = settingSignal('hide_landing_rooms'); + const free_space_list = signal<{ id: string; zones: string[] }[]>([]); + const createComponent = createRoutingFactory({ + component: LandingAvailableNowComponent, + stubsEnabled: false, + detectChanges: false, + imports: [NoopAnimationsModule], + providers: [ + MockProvider(LandingStateService, { + level_occupancy: signal(LEVELS), + free_space_list, + } as any), + MockProvider(SettingsService, { + get: vi.fn(), + overrides: signal([]), + } as any), + MockProvider(OrganisationService, { + initialised: signal(true), + waitUntilInitialised: () => Promise.resolve(), + active_region: signal(null), + active_building: signal({ id: 'bld-1' }), + active_building_loaded: signal(true), + regions: [], + settings: [], + building_list: signal([{ id: 'bld-1' }]), + organisation: { id: 'org-1' }, + building: { id: 'bld-1' }, + buildings: [{ id: 'bld-1', name: 'Building 1' }], + levelWithID: (ids: string[]) => + LEVELS.find((lvl) => ids?.includes(lvl.id)), + } as any), + MockProvider(MatDialog, { open: vi.fn() }), + MockProvider(PaymentsService, {}), + MockProvider(AssetStateService, { + getOptions: vi.fn(() => ({ date: 0 })), + setOptions: vi.fn(), + } as any), + MockProvider(CalendarService, { loadCalendars: vi.fn() } as any), + ], + }); + + /** Wait for the availability query, then render the result */ + const settle = async () => { + spectator.detectChanges(); + await spectator.fixture.whenStable(); + spectator.detectChanges(); + }; + const levelRows = () => + spectator + .queryAll('[name="available-level"]') + .map((row) => row.textContent.replace(/\s+/g, ' ').trim()); + + beforeEach(() => { + vi.clearAllMocks(); + features.set(['desks', 'spaces', 'lockers', 'explore']); + hide_spaces.set(undefined); + hide_rooms.set(undefined); + free_space_list.set([]); + setCurrentUser(new StaffUser({ id: 'me', groups: ['staff'] })); + // Desk list of the building: one request for all levels + vi.mocked(ts_client.listChildMetadata).mockResolvedValue([ + { + zone: { id: 'lvl-1' }, + metadata: { + desks: { + details: [ + { id: 'desk-1', bookable: true }, + { id: 'desk-2', bookable: true }, + { id: 'desk-3', bookable: false }, + { + id: 'desk-4', + bookable: true, + groups: ['managers'], + }, + ], + }, + }, + }, + { + zone: { id: 'lvl-2' }, + metadata: { + desks: { details: [{ id: 'desk-5', bookable: true }] }, + }, + }, + ] as any); + // Booking rules of the building + vi.mocked(ts_client.showMetadata).mockResolvedValue({ + details: [], + } as any); + // Booked desk IDs for the time from now + vi.mocked(ts_client.query).mockResolvedValue({ + data: ['desk-2'], + next: null, + total: 1, + } as any); + spectator = createComponent(); + }); + + it('should count the desks that are free now on a first visit', async () => { + const now = getUnixTime(Date.now()); + await settle(); + + // desk-2 is booked, desk-3 is not bookable, desk-4 is for a group + // that the user is not in. + expect(spectator.component.free_by_level()).toEqual({ + 'lvl-1': 1, + 'lvl-2': 1, + }); + expect(levelRows()).toHaveLength(2); + const { query_params } = vi.mocked(ts_client.query).mock.calls[0][0]; + expect(query_params).toMatchObject({ type: 'desk', zones: 'bld-1' }); + expect(Math.abs(query_params.period_start - now)).toBeLessThan(5); + expect(query_params.period_end - query_params.period_start).toBe(3600); + }); + + it('should make one request each for the list, the rules and the booked desks', async () => { + await settle(); + await settle(); + + expect(ts_client.listChildMetadata).toHaveBeenCalledTimes(1); + expect(ts_client.listChildMetadata).toHaveBeenCalledWith('bld-1', { + name: 'desks', + }); + expect(ts_client.showMetadata).toHaveBeenCalledTimes(1); + expect(ts_client.showMetadata).toHaveBeenCalledWith( + 'bld-1', + 'desk_booking_rules', + ); + expect(ts_client.query).toHaveBeenCalledTimes(1); + }); + + it('should open on the first enabled tab and make no desk request without the desks feature', async () => { + features.set(['spaces', 'explore']); + free_space_list.set([ + { id: 'space-1', zones: ['bld-1', 'lvl-2'] }, + { id: 'space-2', zones: ['bld-1', 'lvl-2'] }, + ]); + await settle(); + + expect(spectator.component.tabs()).toEqual(['rooms']); + expect(spectator.component.active_tab()).toBe('rooms'); + expect(spectator.component.free_by_level()).toEqual({ 'lvl-2': 2 }); + expect(levelRows()).toHaveLength(1); + expect(ts_client.listChildMetadata).not.toHaveBeenCalled(); + expect(ts_client.query).not.toHaveBeenCalled(); + }); + + it('should remove tabs with the landing settings', () => { + hide_spaces.set(true); + expect(spectator.component.tabs()).toEqual(['rooms']); + + hide_spaces.set(false); + hide_rooms.set(true); + expect(spectator.component.tabs()).toEqual(['desks', 'lockers']); + expect(spectator.component.active_tab()).toBe('desks'); + }); + + it('should link to the map only with the explore feature', async () => { + await settle(); + expect(spectator.query('[name="view-map"]')).toBeTruthy(); + expect( + spectator.query('[name="available-level"]').getAttribute('href'), + ).toBe('/explore?zone=lvl-1'); + + features.set(['desks']); + await settle(); + expect(spectator.query('[name="view-map"]')).toBeFalsy(); + expect( + spectator.query('[name="available-level"]').getAttribute('href'), + ).toBeNull(); + }); +}); diff --git a/apps/workplace/src/tests/landing/landing-colleagues-new.component.spec.ts b/apps/workplace/src/tests/landing/landing-colleagues-new.component.spec.ts new file mode 100644 index 0000000000..8ff9418cac --- /dev/null +++ b/apps/workplace/src/tests/landing/landing-colleagues-new.component.spec.ts @@ -0,0 +1,151 @@ +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { signal } from '@angular/core'; +import { OverlayContainer } from '@angular/cdk/overlay'; +import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { BookingFormService } from '@placeos/bookings'; +import { Booking, settingSignal } from '@placeos/common'; +import { EventFormService } from '@placeos/events'; +import { MockProvider } from 'ng-mocks'; +import { LandingStateService } from '../../app/landing/landing-state.service'; +import { LandingColleaguesNewComponent } from '../../app/landing-new/landing-colleagues-new.component'; +import { TeamScheduleService } from '../../app/team-schedule/team-schedule.service'; + +describe('LandingColleaguesNewComponent', () => { + let spectator: Spectator; + const features = settingSignal('features', []); + const booking_form_service = { + newForm: vi.fn(), + setOptions: vi.fn(), + }; + const createComponent = createComponentFactory({ + component: LandingColleaguesNewComponent, + detectChanges: false, + imports: [NoopAnimationsModule], + providers: [ + MockProvider(LandingStateService, { + contacts: signal([ + { + name: 'Test User', + email: 'test@example.com', + }, + ]) as any, + removeContact: vi.fn(), + }), + MockProvider(MatDialog, { + open: vi.fn(), + closeAll: vi.fn(), + }), + MockProvider(EventFormService, { + newForm: vi.fn(), + }), + MockProvider(BookingFormService, booking_form_service), + MockProvider(Router, { navigate: vi.fn() }), + MockProvider(TeamScheduleService, { + isFavorite: vi.fn(() => false), + isTeamMember: vi.fn(() => false), + toggleFavoriteByUser: vi.fn(), + toggleTeamMemberByUser: vi.fn(), + }), + ], + }); + + const open_actions_menu = async () => { + const menu_trigger = spectator + .queryAll('button') + .find((button) => button.textContent?.includes('more_vert')); + spectator.click(menu_trigger as HTMLElement); + await spectator.fixture.whenStable(); + spectator.detectChanges(); + }; + + beforeEach(() => { + features.set(['spaces']); + booking_form_service.newForm.mockClear(); + booking_form_service.setOptions.mockClear(); + }); + + afterEach(() => { + spectator + ?.inject(OverlayContainer) + .getContainerElement() + .replaceChildren(); + spectator?.fixture.destroy(); + }); + + it('should hide favorite and team actions when team schedule is disabled', async () => { + spectator = createComponent(); + spectator.detectChanges(); + await open_actions_menu(); + + const overlay = spectator + .inject(OverlayContainer) + .getContainerElement().textContent; + + expect(overlay).toContain('event'); + expect(overlay).toContain('person_remove'); + expect(overlay).not.toContain('star_outline'); + expect(overlay).not.toContain('group_add'); + }); + + it('should show favorite and team actions when team schedule is enabled', async () => { + features.set(['spaces', 'team-schedule']); + spectator = createComponent(); + spectator.detectChanges(); + await open_actions_menu(); + + const overlay = spectator + .inject(OverlayContainer) + .getContainerElement().textContent; + + expect(overlay).toContain('star_outline'); + expect(overlay).toContain('group_add'); + }); + + it('should show the booking actions of the enabled features only', async () => { + spectator = createComponent(); + spectator.component.selected_users.set([ + { name: 'Test User', email: 'test@example.com' }, + ] as any); + spectator.detectChanges(); + expect(spectator.query('[name="book-room-selected"]')).toBeTruthy(); + expect(spectator.query('[name="book-desk-selected"]')).toBeFalsy(); + + // A desk booking for colleagues is a group booking + const allow_groups = settingSignal('desks.allow_groups', false); + allow_groups.set(false); + features.set(['desks']); + spectator.detectChanges(); + expect(spectator.query('[name="book-room-selected"]')).toBeFalsy(); + expect(spectator.query('[name="book-desk-selected"]')).toBeFalsy(); + + allow_groups.set(true); + spectator.detectChanges(); + expect(spectator.query('[name="book-desk-selected"]')).toBeTruthy(); + await open_actions_menu(); + expect( + spectator.inject(OverlayContainer).getContainerElement() + .textContent, + ).not.toContain('COLLEAGUES_BOOK_MEETING'); + }); + + it('should initialise colleague desk bookings with desk booking type', async () => { + const members = [{ name: 'Test User', email: 'test@example.com' }]; + spectator = createComponent(); + spectator.component.selected_users.set(members as any); + + spectator.component.bookDeskWithSelected(); + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(booking_form_service.newForm).toHaveBeenCalledWith( + 'desk', + expect.objectContaining({ booking_type: 'desk' }) as Booking, + ); + expect(booking_form_service.setOptions).toHaveBeenCalledWith({ + group: true, + members, + }); + expect(spectator.component.selected_users()).toEqual([]); + }); +}); diff --git a/apps/workplace/src/tests/landing/landing-desk-week.component.spec.ts b/apps/workplace/src/tests/landing/landing-desk-week.component.spec.ts new file mode 100644 index 0000000000..ca40db9552 --- /dev/null +++ b/apps/workplace/src/tests/landing/landing-desk-week.component.spec.ts @@ -0,0 +1,256 @@ +import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { + BookingDetailsModalComponent, + BookingFormService, +} from '@placeos/bookings'; +import { + Booking, + i18n, + OrganisationService, + SettingsService, +} from '@placeos/common'; +import { MockProvider } from 'ng-mocks'; +import { + addDays, + addWeeks, + format, + getDay, + startOfWeek, +} from 'date-fns'; +import * as ts_client from '@placeos/ts-client'; +import { ScheduleStateService } from '../../app/schedule/schedule-state.service'; +import { LandingDeskWeekComponent } from '../../app/landing-new/landing-desk-week.component'; + +vi.mock('@placeos/ts-client', { spy: true }); + +const query_bookings = vi.mocked(ts_client.get) as any; + +describe('LandingDeskWeekComponent', () => { + let spectator: Spectator; + const dialog = { open: vi.fn() }; + const router = { navigate: vi.fn() }; + const booking_form = { newForm: vi.fn() }; + const schedule = { + editBooking: vi.fn(), + remove: vi.fn(() => Promise.resolve()), + end: vi.fn(), + }; + const org = { + levelWithID: vi.fn(), + buildings: [] as any[], + }; + const settings = { signal: vi.fn(() => ({})) }; + const createComponent = createComponentFactory({ + component: LandingDeskWeekComponent, + detectChanges: false, + providers: [ + { provide: MatDialog, useValue: dialog }, + { provide: Router, useValue: router }, + { provide: BookingFormService, useValue: booking_form }, + { provide: ScheduleStateService, useValue: schedule }, + { provide: OrganisationService, useValue: org }, + { provide: SettingsService, useValue: settings }, + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + query_bookings.mockResolvedValue([]); + org.levelWithID.mockReturnValue(null); + org.buildings = []; + spectator = createComponent(); + spectator.component.offset_weekday.set(0); + spectator.component.available_days.set(90); + }); + + afterEach(() => spectator?.fixture.destroy()); + + it('should build a seven day week starting on the configured weekday', () => { + const days = spectator.component.weekdays(); + expect(days.length).toBe(7); + // First day matches the configured week start + const expected_start = startOfWeek( + spectator.component.selected_date(), + { weekStartsOn: 0 }, + ); + expect(days[0].id).toBe(format(expected_start, 'yyyy-MM-dd')); + // Exactly one day is flagged as today + expect(days.filter((d) => d.is_today).length).toBe(1); + // Weekend detection matches the real weekday + for (const day of days) { + const dow = getDay(day.date); + expect(day.is_weekend).toBe(dow === 0 || dow === 6); + } + }); + + it('should expose only weekend days via weekend_days', () => { + const weekend = spectator.component.weekend_days(); + expect(weekend.length).toBe(2); + expect(weekend.every((d) => d.is_weekend)).toBe(true); + }); + + it('should report the current week and reset to today', () => { + spectator.component.selected_date.set( + addWeeks(Date.now(), 2).valueOf(), + ); + expect(spectator.component.is_current_week()).toBe(false); + spectator.component.goToToday(); + expect(spectator.component.is_current_week()).toBe(true); + }); + + it('should block navigating to a previous week with no bookable days', () => { + // Current week: the previous week is entirely in the past. + expect(spectator.component.can_go_previous_week()).toBe(false); + const before = spectator.component.selected_date(); + spectator.component.previousWeek(); + expect(spectator.component.selected_date()).toBe(before); + }); + + it('should allow moving to the next week within the booking window', () => { + expect(spectator.component.can_go_next_week()).toBe(true); + const before = spectator.component.selected_date(); + spectator.component.nextWeek(); + expect(spectator.component.selected_date()).toBeGreaterThan(before); + }); + + it('should not move beyond the available booking window', () => { + spectator.component.available_days.set(3); + // Jump far enough forward that the next week is out of range. + spectator.component.selected_date.set( + addDays(Date.now(), 2).valueOf(), + ); + expect(spectator.component.can_go_next_week()).toBe(false); + const before = spectator.component.selected_date(); + spectator.component.nextWeek(); + expect(spectator.component.selected_date()).toBe(before); + }); + + it('should jump to next monday when reachable', () => { + expect(spectator.component.can_go_next_monday()).toBe(true); + spectator.component.goToNextMonday(); + expect(getDay(spectator.component.selected_date())).toBe(1); + }); + + it('should resolve the building name from the organisation data', () => { + org.levelWithID.mockReturnValue({ parent_id: 'bld-1' } as any); + org.buildings = [ + { id: 'bld-1', display_name: 'Head Office', name: 'HQ' }, + ]; + const booking = new Booking({ zones: ['zone-1'] } as any); + expect(spectator.component.buildingName(booking)).toBe('Head Office'); + }); + + it('should fall back to a generic office name without a building', () => { + org.levelWithID.mockReturnValue(null); + const booking = new Booking({ zones: [] } as any); + expect(spectator.component.buildingName(booking)).toBe( + i18n('COMMON.WORK_OFFICE'), + ); + }); + + it('should derive the desk name with sensible fallbacks', () => { + expect( + spectator.component.deskName( + new Booking({ asset_name: 'Desk 12' } as any), + ), + ).toBe('Desk 12'); + expect( + spectator.component.deskName( + new Booking({ description: 'Window desk' } as any), + ), + ).toBe('Window desk'); + expect(spectator.component.deskName(new Booking({} as any))).toBe( + i18n('COMMON.DESK'), + ); + }); + + it('should build an all-day booking tooltip', () => { + const booking = new Booking({ + asset_name: 'Desk 1', + all_day: true, + zones: [], + } as any); + const tooltip = spectator.component.bookingTooltip(booking); + expect(tooltip).toContain('Desk 1'); + expect(tooltip).toContain(i18n('COMMON.ALL_DAY')); + }); + + it('should build a timed booking tooltip', () => { + const date = new Date('2026-07-06T09:00:00').valueOf(); + const booking = new Booking({ + asset_name: 'Desk 1', + date, + duration: 60, + zones: [], + } as any); + const tooltip = spectator.component.bookingTooltip(booking); + expect(tooltip).not.toContain(i18n('COMMON.ALL_DAY')); + expect(tooltip).toMatch(/\d{1,2}:\d{2}/); + }); + + it('should open the booking details modal with lifecycle callbacks', async () => { + const booking = new Booking({ id: 'b1', zones: [] } as any); + spectator.component.viewBooking(booking); + expect(dialog.open).toHaveBeenCalledWith( + BookingDetailsModalComponent, + expect.objectContaining({ + data: expect.objectContaining({ + booking, + edit_fn: expect.any(Function), + remove_fn: expect.any(Function), + end_fn: expect.any(Function), + }), + }), + ); + // The remove callback delegates to the schedule service. + const { remove_fn } = dialog.open.mock.calls[0][1].data; + await remove_fn(booking, 'reason'); + expect(schedule.remove).toHaveBeenCalledWith(booking, 'reason'); + }); + + it('should keep the week in view after the delete prompt closes', async () => { + // `remove` also resolves when the user cancels the prompt + const date = addDays(Date.now(), 21).valueOf(); + spectator.component.selected_date.set(date); + spectator.component.viewBooking(new Booking({ id: 'b1' })); + + const { remove_fn } = dialog.open.mock.calls[0][1].data; + await remove_fn(new Booking({ id: 'b1' })); + + expect(spectator.component.selected_date()).toBe(date); + }); + + it('should navigate and prepare a new desk form when booking', () => { + vi.useFakeTimers(); + const date = Date.now(); + spectator.component.bookDesk(date); + expect(router.navigate).toHaveBeenCalledWith(['/book', 'desk', 'form']); + vi.runAllTimers(); + expect(booking_form.newForm).toHaveBeenCalledWith( + 'desk', + expect.objectContaining({ booking_type: 'desk', date }), + ); + vi.useRealTimers(); + }); + + it('should ignore desk bookings beyond the available window', () => { + const far_future = addDays(Date.now(), 200).valueOf(); + spectator.component.bookDesk(far_future); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should group loaded bookings by day', async () => { + const date = Date.now(); + query_bookings.mockResolvedValue([ + new Booking({ id: 'b1', date, zones: [] } as any), + new Booking({ id: 'b2', date, zones: [] } as any), + ]); + spectator.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + spectator.detectChanges(); + const key = format(date, 'yyyy-MM-dd'); + expect(spectator.component.bookings_by_date()[key]?.length).toBe(2); + }); +}); diff --git a/apps/workplace/src/tests/landing/landing-favourites-new.component.spec.ts b/apps/workplace/src/tests/landing/landing-favourites-new.component.spec.ts new file mode 100644 index 0000000000..f226497751 --- /dev/null +++ b/apps/workplace/src/tests/landing/landing-favourites-new.component.spec.ts @@ -0,0 +1,147 @@ +import { signal } from '@angular/core'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { Router } from '@angular/router'; +import { BookingFormService } from '@placeos/bookings'; +import { + OrganisationService, + SETTING_KEYS, + settingSignal, + SettingsService, +} from '@placeos/common'; +import { EventFormService } from '@placeos/events'; +import { ExploreSpacesService } from '@placeos/explore'; +import { MockProvider } from 'ng-mocks'; +import { LandingStateService } from '../../app/landing/landing-state.service'; +import { LandingFavouritesNewComponent } from '../../app/landing-new/landing-favourites-new.component'; + +describe('LandingFavouritesNewComponent', () => { + let spectator: Spectator; + const booking_model = signal({}); + const createComponent = createComponentFactory({ + component: LandingFavouritesNewComponent, + detectChanges: false, + componentProviders: [MockProvider(ExploreSpacesService)], + providers: [ + MockProvider(OrganisationService, { + organisation: { id: 'org-1' }, + active_building: signal({ id: 'bld-1' }), + } as any), + MockProvider(SettingsService, { + get: vi.fn(), + saveUserSetting: vi.fn(), + }), + MockProvider(LandingStateService, { + space_list: signal([ + { id: 'space-1', name: 'Room 1', email: 'r1@x', zones: [] }, + ]) as any, + }), + MockProvider(EventFormService, { + newForm: vi.fn(), + model: signal({}), + } as any), + MockProvider(BookingFormService, { + loadResourceList: vi.fn((type: string) => + Promise.resolve( + type === 'parking-spaces' + ? [{ id: 'park-123', name: 'Parking 123', zones: [] }] + : [], + ), + ), + newForm: vi.fn(), + setOptions: vi.fn(), + model: booking_model, + } as any), + MockProvider(Router, { navigate: vi.fn() }), + ], + }); + + const features = settingSignal('features', []); + const fav_rooms = settingSignal( + SETTING_KEYS.FAVORITE_ROOMS, + [], + true, + ); + + beforeEach(() => { + vi.clearAllMocks(); + booking_model.set({}); + features.set(['spaces', 'desks', 'parking', 'lockers']); + fav_rooms.set([]); + spectator = createComponent(); + }); + + it('should list favourite rooms on a first visit and only with the spaces feature', () => { + // The room list is from the landing state, not from the event form + fav_rooms.set(['space-1']); + spectator.detectChanges(); + expect(spectator.component.favourites().map((_) => _.id)).toEqual([ + 'space-1', + ]); + + features.set(['desks']); + expect(spectator.component.favourites()).toEqual([]); + }); + + it('should load a resource list only when its feature is enabled', async () => { + features.set(['parking']); + spectator.detectChanges(); + await spectator.fixture.whenStable(); + const load = spectator.inject(BookingFormService).loadResourceList; + expect(load).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledWith('parking-spaces'); + }); + + it('should navigate desk favourites using asset_id query params', () => { + const router = spectator.inject(Router); + const settings = spectator.inject(SettingsService); + const booking_form = spectator.inject(BookingFormService); + (settings.get as any).mockReturnValue(true); + + spectator.component.bookResource({ + id: 'desk-123', + name: 'Desk 123', + type: 'desk', + zones: [], + }); + + expect(router.navigate).toHaveBeenCalledWith(['/book', 'desk'], { + queryParams: { asset_id: 'desk-123' }, + }); + expect(booking_form.newForm).not.toHaveBeenCalled(); + expect(booking_form.setOptions).not.toHaveBeenCalled(); + expect(booking_model()).toEqual({}); + }); + + it('should still preload non-desk favourites into the booking form', async () => { + const router = spectator.inject(Router); + const settings = spectator.inject(SettingsService); + const booking_form = spectator.inject(BookingFormService); + (settings.get as any).mockReturnValue(true); + const parking = { + id: 'park-123', + name: 'Parking 123', + zones: [], + type: 'parking', + } as any; + + // Flush the parking resource loader so the full list is available + spectator.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + spectator.detectChanges(); + + spectator.component.bookResource(parking); + + expect(router.navigate).toHaveBeenCalledWith(['/book', 'parking']); + expect(booking_form.newForm).toHaveBeenCalledWith('parking'); + expect(booking_form.setOptions).toHaveBeenCalledWith({ + type: 'parking', + }); + expect(booking_model()).toEqual( + expect.objectContaining({ + resources: [{ id: 'park-123', name: 'Parking 123', zones: [] }], + asset_id: 'park-123', + booking_type: 'parking', + }), + ); + }); +}); diff --git a/apps/workplace/src/tests/landing/landing-new.component.spec.ts b/apps/workplace/src/tests/landing/landing-new.component.spec.ts new file mode 100644 index 0000000000..200ee85e83 --- /dev/null +++ b/apps/workplace/src/tests/landing/landing-new.component.spec.ts @@ -0,0 +1,152 @@ +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { setCurrentUser, settingSignal, StaffUser } from '@placeos/common'; +import { mockComponent } from '@placeos/common/tests'; +import { MockProvider } from 'ng-mocks'; +import { Subscription } from 'rxjs'; +import { FooterMenuComponent } from '../../app/components/footer-menu.component'; +import { TopbarComponent } from '../../app/components/topbar.component'; +import { VirtualConciergeButtonComponent } from '../../app/components/virtual-concierge-button.component'; +import { LandingStateService } from '../../app/landing/landing-state.service'; +import { LandingAvailableNowComponent } from '../../app/landing-new/landing-available-now.component'; +import { LandingColleaguesNewComponent } from '../../app/landing-new/landing-colleagues-new.component'; +import { LandingDeskWeekComponent } from '../../app/landing-new/landing-desk-week.component'; +import { LandingFavouritesNewComponent } from '../../app/landing-new/landing-favourites-new.component'; +import { LandingNewComponent } from '../../app/landing-new/landing-new.component'; +import { LandingQuickActionsComponent } from '../../app/landing-new/landing-quick-actions.component'; +import { LandingUpcomingBookingComponent } from '../../app/landing-new/landing-upcoming-booking.component'; + +describe('LandingNewComponent', () => { + let spectator: Spectator; + const poll_upcoming_events = vi.fn(() => new Subscription()); + const createComponent = createComponentFactory({ + component: LandingNewComponent, + declarations: [ + mockComponent(TopbarComponent), + mockComponent(FooterMenuComponent), + mockComponent(VirtualConciergeButtonComponent), + mockComponent(LandingUpcomingBookingComponent), + mockComponent(LandingAvailableNowComponent), + mockComponent(LandingQuickActionsComponent), + mockComponent(LandingDeskWeekComponent), + mockComponent(LandingFavouritesNewComponent), + mockComponent(LandingColleaguesNewComponent), + ], + providers: [ + MockProvider(LandingStateService, { + pollUpcomingEvents: poll_upcoming_events as any, + }), + ], + }); + + const features = settingSignal('features', []); + const feature_groups = settingSignal>( + 'feature_groups', + {}, + ); + const hide_colleagues = settingSignal('hide_colleagues'); + const hide_spaces = settingSignal('hide_landing_spaces'); + const hide_rooms = settingSignal('hide_landing_rooms'); + const show_quick_links = settingSignal('show_quick_links'); + const show_quick_book = settingSignal('show_quick_book'); + + beforeEach(() => { + vi.clearAllMocks(); + localStorage.removeItem('PlaceOS.hide_nav'); + features.set(['desks', 'spaces', 'lockers', 'parking', 'explore']); + feature_groups.set({}); + hide_colleagues.set(undefined); + hide_spaces.set(undefined); + hide_rooms.set(undefined); + show_quick_links.set(undefined); + show_quick_book.set(undefined); + setCurrentUser(new StaffUser({ id: 'me', groups: ['staff'] })); + spectator = createComponent(); + }); + + it('should poll upcoming events on init', () => { + spectator.detectChanges(); + expect(poll_upcoming_events).toHaveBeenCalledTimes(1); + }); + + it('should show the top and bottom navigation by default', () => { + spectator.detectChanges(); + expect(spectator.query('topbar')).toBeTruthy(); + expect(spectator.query('footer-menu')).toBeTruthy(); + }); + + it('should hide the navigation when the app has stored hide_nav', () => { + localStorage.setItem('PlaceOS.hide_nav', 'true'); + spectator.detectChanges(); + expect(spectator.query('topbar')).toBeFalsy(); + expect(spectator.query('footer-menu')).toBeFalsy(); + localStorage.removeItem('PlaceOS.hide_nav'); + }); + + it('should render the landing widgets and concierge button', () => { + spectator.detectChanges(); + expect(spectator.query('landing-upcoming-booking')).toBeTruthy(); + expect(spectator.query('landing-available-now')).toBeTruthy(); + expect(spectator.query('landing-desk-week')).toBeTruthy(); + expect(spectator.query('landing-favourites-new')).toBeTruthy(); + expect(spectator.query('landing-colleagues-new')).toBeTruthy(); + expect(spectator.query('virtual-concierge-button')).toBeTruthy(); + }); + + it('should hide the desk widgets when the desks feature is off', () => { + features.set(['spaces', 'explore']); + show_quick_links.set(false); + spectator.detectChanges(); + expect(spectator.query('landing-desk-week')).toBeFalsy(); + // One-click desk booking is the only quick action that is switched on + expect(spectator.query('landing-quick-actions')).toBeFalsy(); + expect(spectator.query('landing-available-now')).toBeTruthy(); + }); + + it('should hide a feature that is limited to other user groups', () => { + feature_groups.set({ desks: ['desk-users'] }); + spectator.detectChanges(); + expect(spectator.query('landing-desk-week')).toBeFalsy(); + + feature_groups.set({ desks: ['staff'] }); + spectator.detectChanges(); + expect(spectator.query('landing-desk-week')).toBeTruthy(); + }); + + it('should hide Available Now when no tab is enabled', () => { + hide_spaces.set(true); + spectator.detectChanges(); + expect(spectator.query('landing-available-now')).toBeTruthy(); + + hide_rooms.set(true); + spectator.detectChanges(); + expect(spectator.query('landing-available-now')).toBeFalsy(); + }); + + it('should show quick actions until both quick settings are false', () => { + spectator.detectChanges(); + expect(spectator.query('landing-quick-actions')).toBeTruthy(); + + show_quick_links.set(false); + show_quick_book.set(false); + spectator.detectChanges(); + expect(spectator.query('landing-quick-actions')).toBeFalsy(); + }); + + it('should hide the colleagues widget when hide_colleagues is set', () => { + hide_colleagues.set(true); + spectator.detectChanges(); + expect(spectator.query('landing-colleagues-new')).toBeFalsy(); + expect(spectator.query('landing-favourites-new')).toBeTruthy(); + }); + + it('should stop polling when destroyed', () => { + const unsub_spy = vi.fn(); + poll_upcoming_events.mockReturnValueOnce({ + unsubscribe: unsub_spy, + } as any); + spectator = createComponent(); + spectator.detectChanges(); + spectator.fixture.destroy(); + expect(unsub_spy).toHaveBeenCalled(); + }); +}); diff --git a/apps/workplace/src/tests/landing/landing-quick-actions.component.spec.ts b/apps/workplace/src/tests/landing/landing-quick-actions.component.spec.ts new file mode 100644 index 0000000000..24af5093e4 --- /dev/null +++ b/apps/workplace/src/tests/landing/landing-quick-actions.component.spec.ts @@ -0,0 +1,77 @@ +import { MatDialog } from '@angular/material/dialog'; +import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/vitest'; +import { settingSignal } from '@placeos/common'; +import { LandingQuickActionsComponent } from '../../app/landing-new/landing-quick-actions.component'; + +describe('LandingQuickActionsComponent', () => { + let spectator: SpectatorRouting; + const features = settingSignal('features', []); + const show_quick_book = settingSignal('show_quick_book'); + const show_quick_links = settingSignal('show_quick_links'); + const show_close = { set: vi.fn() }; + const dialog = { + open: vi.fn(() => ({ componentInstance: { show_close } })), + }; + const createComponent = createRoutingFactory({ + component: LandingQuickActionsComponent, + stubsEnabled: false, + detectChanges: false, + providers: [{ provide: MatDialog, useValue: dialog }], + }); + const linkList = () => + spectator + .queryAll('a[btn]') + .map((a) => a.getAttribute('href')); + const autoAssignButton = () => + spectator.query('button[name="auto-assign-desk"]'); + + beforeEach(() => { + vi.clearAllMocks(); + features.set(['desks', 'spaces', 'parking', 'lockers']); + show_quick_book.set(undefined); + show_quick_links.set(undefined); + spectator = createComponent(); + }); + + it('should hide the actions when the settings are false', () => { + show_quick_book.set(false); + show_quick_links.set(false); + spectator.detectChanges(); + expect(linkList()).toEqual([]); + expect(autoAssignButton()).toBeFalsy(); + }); + + it('should show a booking link for each enabled feature', () => { + show_quick_book.set(false); + spectator.detectChanges(); + expect(linkList()).toEqual([ + '/book/desk', + '/book/meeting', + '/book/parking', + '/book/locker', + ]); + expect(autoAssignButton()).toBeFalsy(); + + features.set(['spaces']); + spectator.detectChanges(); + expect(linkList()).toEqual(['/book/meeting']); + }); + + it('should show the one-click desk booking only with the desks feature', () => { + show_quick_links.set(false); + spectator.detectChanges(); + expect(autoAssignButton()).toBeTruthy(); + expect(linkList()).toEqual([]); + + features.set(['spaces']); + spectator.detectChanges(); + expect(autoAssignButton()).toBeFalsy(); + }); + + it('should open the auto-assign desk modal and reveal its close button', () => { + spectator.detectChanges(); + spectator.component.autoAssignDesk(); + expect(dialog.open).toHaveBeenCalled(); + expect(show_close.set).toHaveBeenCalledWith(true); + }); +}); diff --git a/apps/workplace/src/tests/landing/landing-quick-book.component.spec.ts b/apps/workplace/src/tests/landing/landing-quick-book.component.spec.ts index 90cbc83812..a6d309132a 100644 --- a/apps/workplace/src/tests/landing/landing-quick-book.component.spec.ts +++ b/apps/workplace/src/tests/landing/landing-quick-book.component.spec.ts @@ -12,7 +12,7 @@ import { LandingQuickBookComponent } from '../../app/landing/landing-quick-book. describe('LandingQuickBookComponent', () => { let spectator: Spectator; - let notify_open: ReturnType; + let notify_open: any; const createComponent = createComponentFactory({ component: LandingQuickBookComponent, detectChanges: false, diff --git a/apps/workplace/src/tests/landing/landing-state.service.spec.ts b/apps/workplace/src/tests/landing/landing-state.service.spec.ts index bd01bafaa3..5b89807302 100644 --- a/apps/workplace/src/tests/landing/landing-state.service.spec.ts +++ b/apps/workplace/src/tests/landing/landing-state.service.spec.ts @@ -88,6 +88,55 @@ describe('LandingStateService', () => { expect(spectator.service).toBeTruthy(); }); + it('should exclude inactive bookings from upcoming events', () => { + const date = Date.now() - 60 * 60 * 1000; + filtered_bookings.set([ + new Booking({ + id: 'active', + date, + duration: 24 * 60 - 1, + all_day: true, + status: 'approved', + } as any), + new Booking({ + id: 'checked-out', + date, + duration: 24 * 60 - 1, + all_day: true, + status: 'approved', + checked_out_at: Math.floor(Date.now() / 1000) - 60, + } as any), + new Booking({ + id: 'cancelled', + date, + duration: 24 * 60 - 1, + all_day: true, + status: 'cancelled', + } as any), + new Booking({ + id: 'deleted', + date, + duration: 24 * 60 - 1, + all_day: true, + deleted: true, + } as any), + new CalendarEvent({ + id: 'declined-event', + date, + duration: 24 * 60 - 1, + all_day: true, + status: 'cancelled', + } as any), + ]); + + spectator = createService(); + TestBed.flushEffects(); + + expect(spectator.service.upcoming_events().map((_) => _.id)).toEqual([ + 'active', + ]); + }); + it('should exclude cancelled history before the five-card limit', async () => { const date = new Date().setHours(12, 0, 0, 0); vi.useFakeTimers({ toFake: ['Date'] }); @@ -161,6 +210,41 @@ describe('LandingStateService', () => { expect(spectator.service.upcoming_events()).toEqual([]); }); + it('should list the free spaces that the booking rules allow', async () => { + // The live statuses have the order of the allowed spaces + vi.mocked(ts_client.querySystems).mockResolvedValue({ + data: [ + { id: 'space-hidden', bookable: true, zones: ['lvl-a'] }, + { id: 'space-free', bookable: true, zones: ['lvl-b'] }, + { id: 'space-busy', bookable: true, zones: ['lvl-b'] }, + ], + } as any); + vi.mocked(ts_client.showMetadata).mockResolvedValue({ + details: [{ zone: 'lvl-a', rules: { hidden: true } }], + } as any); + vi.mocked(ts_client.getModule).mockImplementation( + (id: string) => + ({ + variable: () => ({ + bindThenSubscribe: ( + callback: (status: string) => void, + ) => { + callback(id === 'space-free' ? 'free' : 'busy'); + return vi.fn(); + }, + }), + }) as any, + ); + + active_building.set({ id: 'bld-rules' }); + spectator = createService(); + await flush(); + + expect( + spectator.service.free_space_list().map((space) => space.id), + ).toEqual(['space-free']); + }); + it('should not rebind space status when a status value is received', async () => { const bindThenSubscribe = vi.fn((callback) => { callback('free'); diff --git a/apps/workplace/src/tests/landing/landing-upcoming-booking.component.spec.ts b/apps/workplace/src/tests/landing/landing-upcoming-booking.component.spec.ts new file mode 100644 index 0000000000..adaba1f2f0 --- /dev/null +++ b/apps/workplace/src/tests/landing/landing-upcoming-booking.component.spec.ts @@ -0,0 +1,419 @@ +import { signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/vitest'; +import { BookingDetailsModalComponent } from '@placeos/bookings'; +import { Booking, CalendarEvent, OrganisationService } from '@placeos/common'; +import { MockProvider } from 'ng-mocks'; +import { SpacePipe } from 'libs/events/src/lib/space.pipe'; + +vi.mock('@placeos/ts-client', { spy: true }); + +import * as ts_client from '@placeos/ts-client'; +import { LandingUpcomingBookingComponent } from '../../app/landing-new/landing-upcoming-booking.component'; +import { LandingStateService } from '../../app/landing/landing-state.service'; +import { ScheduleStateService } from '../../app/schedule/schedule-state.service'; + +describe('LandingUpcomingBookingComponent', () => { + let spectator: SpectatorRouting; + const upcoming_events = signal([ + new Booking({ + id: 'booking-1', + booking_type: 'desk', + type: 'desk', + asset_id: 'desk-1', + asset_name: 'Desk 1', + description: 'Desk Booking', + date: Date.now(), + duration: 60, + status: 'approved', + } as any), + ]); + const refreshUpcomingEvents = vi.fn(); + const dialog = { + open: vi.fn(), + }; + const createComponent = createRoutingFactory({ + component: LandingUpcomingBookingComponent, + providers: [ + { + provide: MatDialog, + useValue: dialog, + }, + { + provide: LandingStateService, + useValue: { + upcoming_events, + refreshUpcomingEvents, + }, + }, + { + provide: ScheduleStateService, + useValue: { + edit: vi.fn(), + editBooking: vi.fn(), + remove: vi.fn(), + end: vi.fn(), + }, + }, + MockProvider(OrganisationService, { + levelWithID: vi.fn(), + buildings: [], + initialised: signal(true), + } as any), + ], + }); + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.spyOn(SpacePipe.prototype, 'transform').mockImplementation( + async (id: string) => ({ id }) as any, + ); + upcoming_events.set([ + new Booking({ + id: 'booking-1', + booking_type: 'desk', + type: 'desk', + asset_id: 'desk-1', + asset_name: 'Desk 1', + description: 'Desk Booking', + date: Date.now(), + duration: 60, + status: 'approved', + } as any), + ]); + spectator = createComponent(); + }); + + describe('checkIn', () => { + const checkInUrl = () => + vi.mocked(ts_client.post).mock.calls[0]?.[0] as string; + + beforeEach(() => { + vi.mocked(ts_client.post).mockResolvedValue({} as any); + }); + + it('should check in only the occurrence for the first day of a recurring booking', async () => { + // The first day is the series master: it has no `instance` + upcoming_events.set([ + new Booking({ + id: 'series-1', + booking_type: 'desk', + booking_start: 1_790_000_000, + duration: 60, + recurrence_type: 'daily', + }), + ]); + + await spectator.component.checkIn(); + + expect(ts_client.post).toHaveBeenCalledTimes(1); + expect(checkInUrl()).toContain( + '/series-1/check_in/1790000000?state=true', + ); + expect(refreshUpcomingEvents).toHaveBeenCalledTimes(1); + }); + + it('should check in the instance of a later occurrence', async () => { + upcoming_events.set([ + new Booking({ + id: 'series-1', + booking_type: 'desk', + booking_start: 1_790_000_000, + duration: 60, + instance: 1_790_086_400, + }), + ]); + + await spectator.component.checkIn(); + + expect(checkInUrl()).toContain( + '/series-1/check_in/1790086400?state=true', + ); + }); + + it('should check in a booking with no recurrence by its ID', async () => { + await spectator.component.checkIn(); + + expect(checkInUrl()).toContain('/booking-1/check_in?state=true'); + }); + }); + + it('should pass a refresh callback to booking details modal', () => { + vi.useFakeTimers(); + + spectator.component.viewDetails(); + vi.runAllTimers(); + + expect(dialog.open).toHaveBeenCalledWith( + BookingDetailsModalComponent, + expect.objectContaining({ + data: expect.objectContaining({ + refresh_fn: expect.any(Function), + }), + }), + ); + + dialog.open.mock.calls[0][1].data.refresh_fn(); + + expect(refreshUpcomingEvents).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('should disable edit when the booking details modal cannot edit', () => { + upcoming_events.set([ + new Booking({ + id: 'booking-visitor-1', + booking_type: 'visitor', + type: 'visitor', + description: 'Visitor Booking', + date: Date.now(), + duration: 60, + status: 'approved', + } as any), + ]); + spectator.detectChanges(); + + expect(spectator.component.canEdit()).toBe(false); + expect(spectator.queryAll('button[btn]')[1]).toBeDisabled(); + }); + + it('should hide check-in for unallocated parking bookings', () => { + upcoming_events.set([ + new Booking({ + id: 'parking-booking-1', + booking_type: 'parking', + type: 'parking', + asset_id: 'unallocated-123', + date: Date.now(), + duration: 60, + status: 'approved', + } as any), + ]); + spectator.detectChanges(); + + expect(spectator.query('button[btn]:not(.inverse)')).not.toExist(); + const edit_button = spectator.query( + 'button[name="edit-booking"]', + ); + expect(edit_button).toHaveClass('flex-1'); + expect(edit_button).not.toHaveClass('w-12'); + expect(edit_button?.querySelector('div.pr-2')).toExist(); + }); + + it('should show check-in for allocated parking bookings', () => { + upcoming_events.set([ + new Booking({ + id: 'parking-booking-1', + booking_type: 'parking', + type: 'parking', + asset_id: 'parking-123', + date: Date.now(), + duration: 60, + status: 'approved', + } as any), + ]); + spectator.detectChanges(); + + expect(spectator.query('button[btn]:not(.inverse)')).toExist(); + const edit_button = spectator.query( + 'button[name="edit-booking"]', + ); + expect(edit_button).toHaveClass('w-12'); + expect(edit_button).not.toHaveClass('flex-1'); + expect(edit_button?.querySelector('div.pr-2')).not.toExist(); + }); + + it('should keep delete enabled before the shown booking ends', () => { + const date = Date.now(); + upcoming_events.set([ + new Booking({ + id: 'booking-1', + booking_type: 'desk', + type: 'desk', + asset_id: 'desk-1', + asset_name: 'Desk 1', + description: 'Desk Booking', + date, + duration: 60, + date_end: date + 60 * 60 * 1000, + status: 'approved', + } as any), + ]); + spectator.detectChanges(); + + expect(spectator.component.deleteDisabled()).toBe(false); + expect(spectator.queryAll('button[btn]')[3]).not.toBeDisabled(); + }); + + it('should disable delete after the shown booking ends', () => { + const date = Date.now() - 60 * 60 * 1000; + upcoming_events.set([ + new Booking({ + id: 'booking-1', + booking_type: 'desk', + type: 'desk', + asset_id: 'desk-1', + asset_name: 'Desk 1', + description: 'Desk Booking', + date, + duration: 30, + date_end: date + 30 * 60 * 1000, + status: 'approved', + } as any), + ]); + spectator.detectChanges(); + + expect(spectator.component.deleteDisabled()).toBe(true); + expect(spectator.queryAll('button[btn]')[3]).toBeDisabled(); + }); + + it('should disable delete when the booking is ended early before its scheduled end', () => { + const date = Date.now(); + upcoming_events.set([ + new Booking({ + id: 'booking-1', + booking_type: 'desk', + type: 'desk', + asset_id: 'desk-1', + asset_name: 'Desk 1', + description: 'Desk Booking', + date, + duration: 60, + date_end: date + 60 * 60 * 1000, + checked_out_at: Math.floor(date / 1000) - 60, + status: 'approved', + } as any), + ]); + spectator.detectChanges(); + + expect(spectator.component.deleteDisabled()).toBe(true); + expect(spectator.queryAll('button[btn]')[3]).toBeDisabled(); + }); + + it('should disable edit when the event details modal cannot edit', () => { + upcoming_events.set([ + new CalendarEvent({ + id: 'event-shared-1', + title: 'Shared Event', + date: Date.now(), + duration: 60, + status: 'approved', + extension_data: { shared_event: true }, + } as any), + ]); + spectator.detectChanges(); + + expect(spectator.component.canEdit()).toBe(false); + expect(spectator.queryAll('button[btn]')[1]).toBeDisabled(); + }); + + it('should update checked in state immediately for room events', () => { + vi.useFakeTimers(); + const date = Date.now(); + upcoming_events.set([ + new CalendarEvent({ + id: 'event-1', + title: 'Room Booking', + date, + duration: 60, + state: 'started', + status: 'approved', + system: { id: 'sys-1' }, + } as any), + ]); + + // Flush the room-resolving effect (resets room state for new event) + // before simulating the live driver bindings. + spectator.detectChanges(); + spectator.component.room_status.set('busy'); + spectator.component.room_booking_start.set(date / 1000); + + spectator.detectChanges(); + vi.runOnlyPendingTimers(); + spectator.detectChanges(); + + expect(spectator.component.isCheckedIn()).toBe(true); + expect(spectator.query('button[btn]')).toBeDisabled(); + vi.useRealTimers(); + }); + + it('should not show checked in for room events when the driver booking start does not match', () => { + vi.useFakeTimers(); + const date = Date.now(); + upcoming_events.set([ + new CalendarEvent({ + id: 'event-1', + title: 'Room Booking', + date, + duration: 60, + state: 'started', + status: 'approved', + system: { id: 'sys-1' }, + } as any), + ]); + spectator.component.room_status.set('busy'); + spectator.component.room_booking_start.set(date / 1000 + 61); + + spectator.detectChanges(); + vi.runOnlyPendingTimers(); + spectator.detectChanges(); + + expect(spectator.component.isCheckedIn()).toBe(false); + vi.useRealTimers(); + }); + + it('should preserve room status when the same room event refreshes', async () => { + const event_details = { + id: 'event-2', + title: 'Room Booking', + date: Date.now(), + duration: 60, + state: 'started', + status: 'approved', + system: { id: 'calendar-resource-id' }, + }; + upcoming_events.set([new CalendarEvent(event_details as any)]); + // Flush the effect so the event becomes the current room event + spectator.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Simulate the live driver bindings populating room state + spectator.component.room_status.set('busy'); + spectator.component.room_booking_start.set(event_details.date / 1000); + spectator.component.room_system_id.set('room-system-1'); + + // Refresh with the same event - state should be preserved + upcoming_events.set([new CalendarEvent(event_details as any)]); + await new Promise((resolve) => setTimeout(resolve, 0)); + spectator.detectChanges(); + + expect(spectator.component.room_status()).toBe('busy'); + expect(spectator.component.room_booking_start()).toBe( + event_details.date / 1000, + ); + }); + + it('should resolve the room system id like the details modal', async () => { + const transform_spy = SpacePipe.prototype.transform as any; + transform_spy.mockResolvedValue({ id: 'room-system-1' } as any); + upcoming_events.set([ + new CalendarEvent({ + id: 'event-3', + title: 'Room Booking', + date: Date.now(), + duration: 60, + status: 'approved', + system: { id: 'calendar-resource-id' }, + } as any), + ]); + + // Flush the room-resolving effect, then wait for the async lookup + spectator.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + spectator.detectChanges(); + + expect(transform_spy).toHaveBeenCalledWith('calendar-resource-id'); + expect(spectator.component.room_system_id()).toBe('room-system-1'); + }); +}); diff --git a/apps/workplace/src/tests/landing/landing-upcoming.component.spec.ts b/apps/workplace/src/tests/landing/landing-upcoming.component.spec.ts index f159795adb..75b5522257 100644 --- a/apps/workplace/src/tests/landing/landing-upcoming.component.spec.ts +++ b/apps/workplace/src/tests/landing/landing-upcoming.component.spec.ts @@ -71,6 +71,8 @@ describe('LandingUpcomingComponent', () => { it('should not patch resources when editing visitor bookings', () => { vi.useFakeTimers(); const booking_form = spectator.inject(BookingFormService); + (booking_form.newForm as any).mockClear(); + (booking_form.model.update as any).mockClear(); const booking = new Booking({ booking_type: 'visitor', type: 'visitor', @@ -86,6 +88,29 @@ describe('LandingUpcomingComponent', () => { vi.useRealTimers(); }); + it('should not patch resources when editing visitor bookings with only type set', () => { + vi.useFakeTimers(); + const booking_form = spectator.inject(BookingFormService); + (booking_form.newForm as any).mockClear(); + (booking_form.model.update as any).mockClear(); + const booking = new Booking({ + booking_type: ' ', + type: 'visitor', + asset_id: 'visitor@example.com', + asset_name: 'Visitor', + } as any); + + spectator.component.editBooking(booking); + vi.runAllTimers(); + + expect(booking_form.newForm).toHaveBeenLastCalledWith( + 'visitor', + expect.objectContaining({ type: 'visitor' }), + ); + expect(booking_form.model.update).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + it('should show a prompt when the upcoming list is truncated', () => { upcoming_events.set( Array.from( diff --git a/apps/workplace/src/tests/landing/landing-virtual-concierge.component.spec.ts b/apps/workplace/src/tests/landing/landing-virtual-concierge.component.spec.ts new file mode 100644 index 0000000000..f8d2bcce81 --- /dev/null +++ b/apps/workplace/src/tests/landing/landing-virtual-concierge.component.spec.ts @@ -0,0 +1,82 @@ +import { signal } from '@angular/core'; +import { SettingsService } from '@placeos/common'; +import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/vitest'; +import { LandingVirtualConciergeComponent } from '../../app/landing-new/landing-virtual-concierge.component'; + +describe('LandingVirtualConciergeComponent', () => { + let spectator: SpectatorRouting; + const prompt_config = signal(null); + const settings_service = { + signal: vi.fn((key: string) => + key === 'virtual_concierge.prompts' ? prompt_config : signal(null), + ), + }; + const createComponent = createRoutingFactory({ + component: LandingVirtualConciergeComponent, + stubsEnabled: false, + detectChanges: false, + providers: [{ provide: SettingsService, useValue: settings_service }], + }); + + beforeEach(() => { + vi.clearAllMocks(); + prompt_config.set(null); + spectator = createComponent(); + spectator.component.features.set([]); + }); + + it('should fall back to default prompts when none are configured', () => { + expect(spectator.component.prompts()).toEqual([ + 'Book a meeting room tomorrow at 2pm for 4 people', + 'Find me an available desk near a window', + 'Reserve parking for Friday morning', + ]); + }); + + it('should use configured prompts when they are an array', () => { + prompt_config.set(['Ask me one thing', 'Ask me another']); + expect(spectator.component.prompts()).toEqual([ + 'Ask me one thing', + 'Ask me another', + ]); + }); + + it('should toggle fullscreen state', () => { + expect(spectator.component.is_fullscreen()).toBe(false); + spectator.component.openFullscreen(); + expect(spectator.component.is_fullscreen()).toBe(true); + spectator.component.closeFullscreen(); + expect(spectator.component.is_fullscreen()).toBe(false); + }); + + it('should render the compact card and hide the overlay by default', () => { + spectator.detectChanges(); + expect(spectator.query('.fixed.inset-0')).toBeFalsy(); + expect(spectator.query('img[src="assets/icons/roybot.png"]')).toBeTruthy(); + }); + + it('should render the fullscreen overlay when opened', () => { + spectator.component.openFullscreen(); + spectator.detectChanges(); + expect(spectator.query('.fixed.inset-0')).toBeTruthy(); + }); + + it('should gate quick action links by enabled features', () => { + spectator.component.features.set(['spaces', 'lockers']); + spectator.detectChanges(); + const hrefs = spectator + .queryAll('a[btn]') + .map((a) => a.getAttribute('href')); + expect(hrefs).toContain('/book/meeting'); + expect(hrefs).toContain('/book/locker'); + expect(hrefs).not.toContain('/book/desk'); + expect(hrefs).not.toContain('/book/parking'); + }); + + it('should close the overlay when the backdrop is clicked', () => { + spectator.component.openFullscreen(); + spectator.detectChanges(); + spectator.click('.fixed.inset-0'); + expect(spectator.component.is_fullscreen()).toBe(false); + }); +}); diff --git a/apps/workplace/src/tests/landing/landing.component.spec.ts b/apps/workplace/src/tests/landing/landing.component.spec.ts index c5ef962311..34743fd9f7 100644 --- a/apps/workplace/src/tests/landing/landing.component.spec.ts +++ b/apps/workplace/src/tests/landing/landing.component.spec.ts @@ -43,6 +43,8 @@ describe('LandingComponent', () => { it('should match snapshot', () => { spectator.component.time.set(1); spectator.detectChanges(); - expect('[date]').toContainText('1970'); + expect(spectator.query('[date]')?.textContent || '').toMatch( + /1969|1970/, + ); }); }); diff --git a/apps/workplace/src/tests/schedule/schedule-day-view.component.spec.ts b/apps/workplace/src/tests/schedule/schedule-day-view.component.spec.ts new file mode 100644 index 0000000000..9e8009d55a --- /dev/null +++ b/apps/workplace/src/tests/schedule/schedule-day-view.component.spec.ts @@ -0,0 +1,409 @@ +import { MatDialog } from '@angular/material/dialog'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { BookingDetailsModalComponent } from '@placeos/bookings'; +import { Booking, CalendarEvent, OrganisationService } from '@placeos/common'; +import { + EventDetailsModalComponent, + GroupEventDetailsModalComponent, +} from '@placeos/events'; +import { UserPipe } from '@placeos/users'; +import { addDays, addHours, startOfDay } from 'date-fns'; +import { MockPipe, MockProvider } from 'ng-mocks'; +import { ScheduleDayViewComponent } from '../../app/schedule/schedule-day-view.component'; +import { ScheduleStateService } from '../../app/schedule/schedule-state.service'; + +describe('ScheduleDayViewComponent', () => { + let spectator: Spectator; + let dialog: MatDialog; + + // Fixed reference day (avoids "ended" status surprises for positioning tests) + const the_day = startOfDay(new Date('2026-05-14T00:00:00')).valueOf(); + + const bookingAt = ( + start_hour: number, + duration_minutes: number, + extra: Record = {}, + ) => { + const date = addHours(the_day, start_hour).valueOf(); + return new Booking({ + id: extra.id || `bkn-${start_hour}-${duration_minutes}`, + booking_type: 'desk', + date, + duration: duration_minutes, + title: `Booking ${start_hour}`, + ...extra, + }); + }; + + const createComponent = createComponentFactory({ + component: ScheduleDayViewComponent, + declarations: [MockPipe(UserPipe, (value) => value as any)], + providers: [ + MockProvider(MatDialog, { open: vi.fn(), closeAll: vi.fn() }), + MockProvider(OrganisationService, { levelWithID: vi.fn() }), + { + provide: ScheduleStateService, + useValue: { + edit: vi.fn(), + editBooking: vi.fn(), + remove: vi.fn(), + end: vi.fn(), + triggerPoll: vi.fn(), + }, + }, + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + spectator = createComponent({ props: { date: the_day } }); + dialog = spectator.inject(MatDialog); + }); + + it('should create component', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should build 24 hourly time slots for the whole day', () => { + const slots = spectator.component.timeSlots(); + expect(slots).toHaveLength(24); + expect(slots[0].hour).toBe(0); + expect(slots[23].hour).toBe(23); + // labels are formatted as "12 AM", "1 AM", ... "12 PM" ... + expect(slots[0].label).toBe('12 AM'); + expect(slots[13].label).toBe('1 PM'); + }); + + it('should filter bookings down to the selected day', () => { + const today_booking = bookingAt(9, 60, { id: 'today' }); + const other_day = new Booking({ + id: 'other', + booking_type: 'desk', + date: addDays(the_day, 2).valueOf(), + duration: 60, + }); + spectator.setInput('bookings', [today_booking, other_day]); + const day = spectator.component.dayBookings(); + expect(day.map((b) => b.id)).toEqual(['today']); + }); + + it('should show a multi-day booking on a middle day, for the full day', () => { + const booking = new Booking({ + id: 'multi', + booking_type: 'parking', + date: addDays(the_day, -1).valueOf(), + duration: 3 * 24 * 60, + all_day: true, + }); + spectator.setInput('bookings', [booking]); + + const [item] = spectator.component.positionedBookings(); + expect(item.booking.id).toBe('multi'); + expect(item.top).toBe(0); + expect(item.height).toBe(100); + spectator.detectChanges(); + expect(spectator.query('.mb-4')?.textContent).toContain('1 booking'); + + spectator.setInput('date', addDays(the_day, 2).valueOf()); + expect(spectator.component.dayBookings()).toEqual([]); + }); + + it('should render a booking count that is pluralised', () => { + spectator.setInput('bookings', [bookingAt(9, 60)]); + spectator.detectChanges(); + expect(spectator.query('.mb-4')?.textContent).toContain('1 booking'); + expect(spectator.query('.mb-4')?.textContent).not.toContain('bookings'); + + spectator.setInput('bookings', [bookingAt(9, 60), bookingAt(11, 60)]); + spectator.detectChanges(); + expect(spectator.query('.mb-4')?.textContent).toContain('2 bookings'); + }); + + it('should give non-overlapping bookings the full width in one column', () => { + spectator.setInput('bookings', [ + bookingAt(9, 60, { id: 'a' }), + bookingAt(11, 60, { id: 'b' }), + ]); + const positioned = spectator.component.positionedBookings(); + expect(positioned).toHaveLength(2); + for (const item of positioned) { + expect(item.totalColumns).toBe(1); + expect(item.width).toBe(100); + expect(item.left).toBe(0); + } + }); + + it('should split overlapping bookings into side-by-side columns', () => { + spectator.setInput('bookings', [ + bookingAt(9, 120, { id: 'a' }), // 9-11 + bookingAt(10, 60, { id: 'b' }), // 10-11 overlaps a + ]); + const positioned = spectator.component.positionedBookings(); + expect(positioned).toHaveLength(2); + const by_id = Object.fromEntries( + positioned.map((p) => [p.booking.id, p]), + ); + expect(by_id['a'].totalColumns).toBe(2); + expect(by_id['b'].totalColumns).toBe(2); + expect(by_id['a'].column).toBe(0); + expect(by_id['b'].column).toBe(1); + // Columns should not overlap horizontally + expect(by_id['b'].left).toBeGreaterThan(by_id['a'].left); + expect(by_id['a'].width).toBeLessThan(100); + }); + + it('should compute a proportional top and height for a booking', () => { + // 6am booking, one hour long, over a 24h grid + spectator.setInput('bookings', [bookingAt(6, 60, { id: 'a' })]); + const [item] = spectator.component.positionedBookings(); + // 6/24 hours from top = 25% + expect(item.top).toBeCloseTo(25, 1); + // 1 hour of 24 = ~4.16% + expect(item.height).toBeCloseTo(100 / 24, 1); + }); + + it('should enforce a minimum height for very short bookings', () => { + spectator.setInput('bookings', [bookingAt(6, 1, { id: 'a' })]); + const [item] = spectator.component.positionedBookings(); + expect(item.height).toBeGreaterThanOrEqual(1); + }); + + it('should return no positioned bookings when the day is empty', () => { + spectator.setInput('bookings', []); + expect(spectator.component.positionedBookings()).toEqual([]); + }); + + describe('type()', () => { + it('returns the booking_type for Booking instances', () => { + expect( + spectator.component.type( + new Booking({ booking_type: 'parking' }), + ), + ).toBe('parking'); + }); + + it('returns "event" for a plain calendar event', () => { + const event = new CalendarEvent({ id: 'e1', title: 'Meet' }); + expect(spectator.component.type(event)).toBe('event'); + }); + + it('returns "group-event" for a shared calendar event', () => { + const event = new CalendarEvent({ + id: 'e2', + title: 'Meet', + extension_data: { shared_event: true }, + }); + expect(spectator.component.type(event)).toBe('group-event'); + }); + }); + + describe('bookingStatus()', () => { + afterEach(() => vi.restoreAllMocks()); + + it('returns "approved" for an approved future booking', () => { + const booking = new Booking({ + booking_type: 'desk', + date: addDays(Date.now(), 1).valueOf(), + duration: 60, + status: 'approved', + }); + expect(spectator.component.bookingStatus(booking)).toBe('approved'); + }); + + it('flags a tentative parking booking in the active waitlist week as waitlisted', () => { + // Friday 18:00 local is the default start of the waitlist week + vi.spyOn(Date, 'now').mockReturnValue( + new Date(2026, 6, 31, 18, 0).valueOf(), + ); + const booking = new Booking({ + booking_type: 'parking', + date: new Date(2026, 7, 3, 8, 0).valueOf(), + duration: 60, + }); + expect(booking.status).toBe('tentative'); + expect(spectator.component.bookingStatus(booking)).toBe( + 'waitlisted', + ); + }); + + it('leaves a tentative parking booking past the waitlist week as tentative', () => { + vi.spyOn(Date, 'now').mockReturnValue( + new Date(2026, 6, 31, 17, 59).valueOf(), + ); + const booking = new Booking({ + booking_type: 'parking', + date: new Date(2026, 7, 3, 8, 0).valueOf(), + duration: 60, + }); + expect(spectator.component.bookingStatus(booking)).toBe( + 'tentative', + ); + }); + + it('returns null for statuses outside the known set (e.g. ended)', () => { + // a past booking is "ended" which is not surfaced + const booking = bookingAt(9, 60); + expect(booking.status).toBe('ended'); + expect(spectator.component.bookingStatus(booking)).toBeNull(); + }); + }); + + describe('statusLabel() / statusColor()', () => { + it('capitalises the status label', () => { + expect(spectator.component.statusLabel('approved')).toBe( + 'Approved', + ); + expect(spectator.component.statusLabel('waitlisted')).toBe( + 'Waitlisted', + ); + }); + + it('maps each status to a themed colour variable', () => { + expect(spectator.component.statusColor('approved')).toBe( + 'var(--success)', + ); + expect(spectator.component.statusColor('waitlisted')).toBe( + 'var(--info)', + ); + expect(spectator.component.statusColor('tentative')).toBe( + 'var(--warn)', + ); + expect(spectator.component.statusColor('declined')).toBe( + 'var(--error)', + ); + }); + }); + + it('should hide the visitor name for non-visitor bookings', () => { + const parking = new Booking({ + booking_type: 'parking', + asset_id: 'unallocated-123', + }); + expect(spectator.component.visitorName(parking)).toBe(''); + }); + + it('should hide the asset id of unallocated parking bookings', () => { + const unallocated = new Booking({ + booking_type: 'parking', + asset_id: 'unallocated-123', + }); + expect(spectator.component.location(unallocated)).toBe(''); + }); + + describe('backgroundColor()', () => { + it('uses the solid type colour for the current user', () => { + const booking = new Booking({ booking_type: 'desk' }); + expect(spectator.component.backgroundColor(booking)).toBe( + '#BFDBFE', + ); + }); + + it('adds transparency when the booking is for another user', () => { + const booking = new Booking({ + booking_type: 'desk', + booked_by_email: 'me@place.tech', + user_email: 'other@place.tech', + }); + vi.spyOn( + spectator.component, + 'isBookingForOtherUser', + ).mockReturnValue(true as any); + expect(spectator.component.backgroundColor(booking)).toBe( + '#BFDBFE80', + ); + }); + }); + + it('should scroll to the current time one time for each date change', () => { + vi.useFakeTimers(); + const scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + spectator.setInput('date', Date.now()); + spectator.detectChanges(); + vi.advanceTimersByTime(100); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + // The time marker moves every 10 seconds + vi.advanceTimersByTime(30 * 1000); + spectator.detectChanges(); + vi.advanceTimersByTime(100); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + spectator.setInput('date', Date.now()); + spectator.detectChanges(); + vi.advanceTimersByTime(100); + expect(scrollIntoView).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + + describe('currentTimePosition()', () => { + it('returns null when the viewed day is not today', () => { + // component date input is the fixed 2026 day, not today + expect(spectator.component.isToday()).toBe(false); + expect(spectator.component.currentTimePosition()).toBeNull(); + }); + + it('returns a percentage within range when viewing today', () => { + spectator.setInput('date', Date.now()); + expect(spectator.component.isToday()).toBe(true); + const pos = spectator.component.currentTimePosition(); + expect(pos).not.toBeNull(); + expect(pos).toBeGreaterThanOrEqual(0); + expect(pos).toBeLessThanOrEqual(100); + }); + }); + + describe('viewBooking()', () => { + it('closes any open dialogs first', () => { + spectator.component.viewBooking(bookingAt(9, 60)); + expect(dialog.closeAll).toHaveBeenCalled(); + }); + + it('opens the event modal for a plain calendar event', () => { + const event = new CalendarEvent({ id: 'e1', title: 'Meet' }); + spectator.component.viewBooking(event); + expect(dialog.open).toHaveBeenCalledWith( + EventDetailsModalComponent, + expect.anything(), + ); + }); + + it('opens the group modal for a shared calendar event', () => { + const event = new CalendarEvent({ + id: 'e2', + title: 'Meet', + extension_data: { shared_event: true }, + }); + spectator.component.viewBooking(event); + expect(dialog.open).toHaveBeenCalledWith( + GroupEventDetailsModalComponent, + expect.anything(), + ); + }); + + it('opens the booking details modal for a normal booking', () => { + spectator.component.viewBooking(bookingAt(9, 60)); + expect(dialog.open).toHaveBeenCalledWith( + BookingDetailsModalComponent, + expect.anything(), + ); + const state = spectator.inject(ScheduleStateService); + (dialog.open as any).mock.calls[0][1].data.refresh_fn(); + expect(state.triggerPoll).toHaveBeenCalled(); + }); + + it('opens the group modal for a group-event booking', () => { + const booking = new Booking({ + id: 'g1', + booking_type: 'group-event', + date: the_day, + duration: 60, + }); + spectator.component.viewBooking(booking); + expect(dialog.open).toHaveBeenCalledWith( + GroupEventDetailsModalComponent, + expect.anything(), + ); + }); + }); +}); diff --git a/apps/workplace/src/tests/schedule/schedule-list-view.component.spec.ts b/apps/workplace/src/tests/schedule/schedule-list-view.component.spec.ts new file mode 100644 index 0000000000..1bc4bd68ab --- /dev/null +++ b/apps/workplace/src/tests/schedule/schedule-list-view.component.spec.ts @@ -0,0 +1,206 @@ +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { BookingCardComponent } from '@placeos/bookings'; +import { Booking, CalendarEvent } from '@placeos/common'; +import { TranslatePipe } from '@placeos/components'; +import { EventCardComponent } from '@placeos/events'; +import { addDays, addHours, startOfDay } from 'date-fns'; +import { MockComponent, MockPipe } from 'ng-mocks'; +import { ScheduleListViewComponent } from '../../app/schedule/schedule-list-view.component'; +import { ScheduleStateService } from '../../app/schedule/schedule-state.service'; + +describe('ScheduleListViewComponent', () => { + let spectator: Spectator; + const state = { + edit: vi.fn(), + editBooking: vi.fn(), + remove: vi.fn(), + end: vi.fn(), + triggerPoll: vi.fn(), + }; + + const day_one = startOfDay(new Date('2026-05-14T00:00:00')).valueOf(); + + const bookingOn = (date: number, id: string) => + new Booking({ + id, + booking_type: 'desk', + date: addHours(date, 9).valueOf(), + duration: 60, + title: id, + }); + + const createComponent = createComponentFactory({ + component: ScheduleListViewComponent, + declarations: [ + MockPipe(TranslatePipe, (key) => key), + MockComponent(EventCardComponent), + MockComponent(BookingCardComponent), + ], + providers: [{ provide: ScheduleStateService, useValue: state }], + }); + + beforeEach(() => { + vi.clearAllMocks(); + spectator = createComponent({ props: { date: day_one } }); + }); + + it('labels cancelled bookings while retaining their cards', async () => { + const active = bookingOn(day_one, 'active'); + const cancelled = new Booking({ + ...bookingOn(day_one, 'cancelled'), + deleted: true, + }); + spectator.setInput('bookings', [active, cancelled]); + await spectator.fixture.whenStable(); + + expect(spectator.queryAll('booking-card')).toHaveLength(2); + expect( + spectator.queryAll('booking-card [booking-status]'), + ).toHaveLength(1); + expect( + spectator.query('booking-card [booking-status]')?.textContent, + ).toContain('COMMON.TYPE_CANCELLED'); + }); + + it('should create component', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should identify calendar events with isEvent', () => { + expect( + spectator.component.isEvent(new CalendarEvent({ id: 'e' })), + ).toBe(true); + expect( + spectator.component.isEvent(new Booking({ booking_type: 'desk' })), + ).toBe(false); + }); + + it('should group the selected single day into one date block', () => { + spectator.setInput('bookings', [ + bookingOn(day_one, 'a'), + bookingOn(day_one, 'b'), + ]); + const blocks = spectator.component.booking_dates(); + expect(blocks).toHaveLength(1); + expect(blocks[0].bookings.map((b: Booking) => b.id)).toEqual([ + 'a', + 'b', + ]); + }); + + it('should exclude bookings from other days in single-date mode', () => { + spectator.setInput('bookings', [ + bookingOn(day_one, 'a'), + bookingOn(addDays(day_one, 3).valueOf(), 'later'), + ]); + const blocks = spectator.component.booking_dates(); + expect(blocks).toHaveLength(1); + expect(blocks[0].bookings.map((b: Booking) => b.id)).toEqual(['a']); + }); + + it('should mark the block containing today with is_today', () => { + const today = startOfDay(Date.now()).valueOf(); + spectator.setInput('date', today); + spectator.setInput('bookings', [bookingOn(today, 'now')]); + const blocks = spectator.component.booking_dates(); + expect(blocks[0].is_today).toBe(true); + }); + + it('should not mark a non-today block as today', () => { + spectator.setInput('bookings', [bookingOn(day_one, 'a')]); + expect(spectator.component.booking_dates()[0].is_today).toBe(false); + }); + + it('should produce a block per day across a date range, sorted by date', () => { + const day_three = addDays(day_one, 2).valueOf(); + spectator.setInput('end_date', day_three); + spectator.setInput('bookings', [ + bookingOn(day_three, 'c'), + bookingOn(day_one, 'a'), + ]); + const blocks = spectator.component.booking_dates(); + expect(blocks).toHaveLength(2); + // sorted ascending by date + expect(blocks[0].bookings[0].id).toBe('a'); + expect(blocks[1].bookings[0].id).toBe('c'); + }); + + it('should list a multi-day booking on each day of the range that it covers', () => { + const multi = new Booking({ + id: 'multi', + booking_type: 'parking', + date: addDays(day_one, -1).valueOf(), + duration: 3 * 24 * 60, + all_day: true, + }); + spectator.setInput('bookings', [multi]); + // Single day: the booking started on the day before + expect(spectator.component.booking_dates().map((_) => _.date)).toEqual([ + day_one, + ]); + + spectator.setInput('end_date', addDays(day_one, 3).valueOf()); + expect(spectator.component.booking_dates().map((_) => _.date)).toEqual([ + day_one, + addDays(day_one, 1).valueOf(), + ]); + }); + + it('should exclude bookings outside the date range', () => { + const day_two = addDays(day_one, 1).valueOf(); + spectator.setInput('end_date', day_two); + spectator.setInput('bookings', [ + bookingOn(day_one, 'a'), + bookingOn(addDays(day_one, 10).valueOf(), 'far'), + ]); + const blocks = spectator.component.booking_dates(); + const all_ids = blocks.flatMap((b: any) => + b.bookings.map((x: Booking) => x.id), + ); + expect(all_ids).toContain('a'); + expect(all_ids).not.toContain('far'); + }); + + it('should return no blocks while loading', () => { + spectator.setInput('loading', true); + spectator.setInput('bookings', [bookingOn(day_one, 'a')]); + expect(spectator.component.booking_dates()).toEqual([]); + }); + + it('should show the empty state when there are no bookings', () => { + spectator.setInput('bookings', []); + spectator.detectChanges(); + expect( + spectator.query('img[src="assets/img/no-events.svg"]'), + ).toExist(); + expect(spectator.query('.opacity-30')?.textContent).toContain( + 'APP.WORKPLACE.SCHEDULE_EMPTY', + ); + }); + + it('should render an event-card for events and booking-card for bookings', () => { + spectator.setInput('bookings', [ + bookingOn(day_one, 'a'), + Object.assign(new CalendarEvent({ id: 'e1', title: 'Meet' }), { + date: addHours(day_one, 10).valueOf(), + }), + ]); + spectator.detectChanges(); + expect(spectator.query('booking-card')).toExist(); + expect(spectator.query('event-card')).toExist(); + }); + + it('should wire card callbacks through to the state service', () => { + const booking = bookingOn(day_one, 'a'); + spectator.component.edit_fn(booking as any); + spectator.component.edit_booking_fn(booking); + spectator.component.remove_fn(booking, true); + spectator.component.end_fn(booking); + spectator.component.refresh_fn(); + expect(state.edit).toHaveBeenCalledWith(booking); + expect(state.editBooking).toHaveBeenCalledWith(booking); + expect(state.remove).toHaveBeenCalledWith(booking, true); + expect(state.end).toHaveBeenCalledWith(booking); + expect(state.triggerPoll).toHaveBeenCalled(); + }); +}); diff --git a/apps/workplace/src/tests/schedule/schedule-state.service.spec.ts b/apps/workplace/src/tests/schedule/schedule-state.service.spec.ts index e4c0ffae29..b98d87ee5f 100644 --- a/apps/workplace/src/tests/schedule/schedule-state.service.spec.ts +++ b/apps/workplace/src/tests/schedule/schedule-state.service.spec.ts @@ -1,20 +1,46 @@ import { signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; import { createServiceFactory, SpectatorService, } from '@ngneat/spectator/vitest'; +import { BookingFormService, ParkingService } from '@placeos/bookings'; +import { + Booking, + CalendarEvent, + OrganisationService, + SettingsService, +} from '@placeos/common'; +import { + EventFormService, + newCalendarEventFromBooking, + SpacesService, +} from '@placeos/events'; +import { endOfDay, getUnixTime, startOfDay } from 'date-fns'; import { MockProvider } from 'ng-mocks'; +import { NEVER, of } from 'rxjs'; vi.mock('@placeos/ts-client', { spy: true }); -import { ParkingService } from '@placeos/bookings'; -import { Booking, OrganisationService, SettingsService } from '@placeos/common'; import * as ts_client from '@placeos/ts-client'; -import { ScheduleStateService } from '../../app/schedule/schedule-state.service'; +import { + isBookingForOtherUser, + overlapsDay, + ScheduleStateService, +} from '../../app/schedule/schedule-state.service'; describe('ScheduleStateService', () => { let spectator: SpectatorService; - const parking_factory = vi.fn(() => ({})); + const router = { navigate: vi.fn() }; + const event_form = { newForm: vi.fn() }; + const spaces = { + find: vi.fn(), + loadSpace: vi.fn(), + }; + const parking_factory = vi.fn(() => ({ + spaces: signal([{ id: 'space-1', name: 'Bay 1', identifier: 'B1' }]), + })); const createService = createServiceFactory({ service: ScheduleStateService, providers: [ @@ -27,9 +53,34 @@ describe('ScheduleStateService', () => { } as any), MockProvider(OrganisationService, { active_building: signal({} as any), + active_region: signal(null), building_list: signal([]), level_list: signal([]), - }), + module: vi.fn(() => null), + } as any), + MockProvider(MatDialog, { + closeAll: vi.fn(), + open: vi.fn(() => ({ + componentInstance: { + event: of({ reason: 'done' }), + loading: signal(''), + }, + afterClosed: () => of(null), + close: vi.fn(), + })) as any, + } as any), + MockProvider(Router, router), + MockProvider(EventFormService, event_form), + MockProvider(BookingFormService, { + newForm: vi.fn(), + model: Object.assign( + vi.fn(() => ({})), + { + update: vi.fn(), + }, + ), + } as any), + MockProvider(SpacesService, spaces), { provide: ParkingService, useFactory: parking_factory, @@ -38,7 +89,12 @@ describe('ScheduleStateService', () => { }); beforeEach(() => { + vi.useFakeTimers(); vi.clearAllMocks(); + router.navigate.mockClear(); + event_form.newForm.mockClear(); + spaces.find.mockReset(); + spaces.loadSpace.mockReset(); // The schedule fns (queryAllBookings/queryEvents/requestSpacesForZone/ // loadLockerResources) are workspace fns that can't be spied; they all // funnel into ts-client `get`/`query`/`querySystems`, so stub + assert @@ -54,13 +110,151 @@ describe('ScheduleStateService', () => { vi.mocked(ts_client.querySystems).mockResolvedValue({ data: [], } as any); + vi.mocked(ts_client.post).mockResolvedValue({} as any); + vi.mocked(ts_client.del).mockResolvedValue(undefined as any); spectator = createService(); }); + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + it('should create service', () => { expect(spectator.service).toBeTruthy(); }); + it('should resolve the space name of allocated parking bookings', () => { + const list = [ + new Booking({ + id: 'bk-1', + booking_type: 'parking', + asset_id: 'space-1', + asset_name: 'space-1', + }), + ]; + const [allocated] = (spectator.service as any)._resolveParkingNames( + list, + ); + expect(allocated.asset_name).toBe('Bay 1'); + }); + + it('should use the event system as selected room when resources are missing', async () => { + const room = { + id: 'room-1', + email: 'room-1@example.com', + name: 'Boardroom', + display_name: 'Boardroom', + zones: ['lvl-1'], + }; + spaces.find.mockImplementation((id) => + id === 'room-1' ? room : undefined, + ); + const event = new CalendarEvent({ + id: 'event-1', + creator: 'user@example.com', + mailbox: 'user@example.com', + system: { id: 'room-1', name: 'Boardroom' } as any, + resources: [], + title: 'Existing booking', + date: new Date(2028, 5, 15, 10, 0, 0, 0).valueOf(), + duration: 60, + }); + + await spectator.service.edit(event); + vi.advanceTimersByTime(300); + + expect(router.navigate).toHaveBeenCalledWith([ + '/book', + 'meeting', + 'form', + ]); + expect(event_form.newForm).toHaveBeenCalledWith( + expect.objectContaining({ + resources: [room], + }), + ); + }); + + it('should not patch resources when editing visitor bookings', () => { + const booking_form = spectator.inject(BookingFormService); + const booking = new Booking({ + booking_type: 'visitor', + type: 'visitor', + asset_id: 'visitor@example.com', + asset_name: 'Visitor', + } as any); + + spectator.service.editBooking(booking); + vi.runAllTimers(); + + expect(booking_form.newForm).toHaveBeenCalledWith('visitor', booking); + expect(booking_form.model.update).not.toHaveBeenCalled(); + }); + + it('should patch resources when editing non-visitor bookings', () => { + const booking_form = spectator.inject(BookingFormService); + const booking = new Booking({ + booking_type: 'desk', + type: 'desk', + asset_id: 'desk-1', + asset_name: 'Desk 1', + } as any); + + spectator.service.editBooking(booking); + vi.runAllTimers(); + + expect(booking_form.model.update).toHaveBeenCalled(); + const updater = (booking_form.model.update as any).mock.calls[0][0]; + expect(updater({})).toEqual({ + resources: [{ id: 'desk-1', name: 'Desk 1' }], + asset_id: 'desk-1', + }); + }); + + it('should refresh ended bookings without hiding them as deleted', async () => { + const now = new Date(2026, 5, 23, 10).valueOf(); + vi.setSystemTime(now); + const triggerPoll = vi.spyOn(spectator.service, 'triggerPoll'); + const removeItem = vi.spyOn(spectator.service, 'removeItem'); + const booking = new Booking({ + id: 'booking-1', + booking_type: 'desk', + type: 'desk', + asset_id: 'desk-1', + asset_name: 'Desk 1', + date: now - 10 * 60 * 1000, + duration: 60, + } as any); + + await spectator.service.end(booking); + + expect(ts_client.post).toHaveBeenCalled(); + const [url] = vi.mocked(ts_client.post).mock.calls[0]; + expect(url).toContain('booking-1'); + expect(url).toContain('check_in'); + expect(triggerPoll).toHaveBeenCalled(); + expect(removeItem).not.toHaveBeenCalled(); + }); + + it('should refresh cancelled bookings without hiding them as deleted', async () => { + const triggerPoll = vi.spyOn(spectator.service, 'triggerPoll'); + const removeItem = vi.spyOn(spectator.service, 'removeItem'); + const booking = new Booking({ + id: 'booking-1', + booking_type: 'visitor', + type: 'visitor', + asset_id: 'visitor@example.com', + asset_name: 'Visitor', + } as any); + + await spectator.service.remove(booking); + + expect(ts_client.del).toHaveBeenCalled(); + expect(triggerPoll).toHaveBeenCalled(); + expect(removeItem).not.toHaveBeenCalled(); + }); + it('should not make schedule requests before schedule data is consumed', () => { expect(parking_factory).not.toHaveBeenCalled(); expect(ts_client.get).not.toHaveBeenCalled(); @@ -97,6 +291,107 @@ describe('ScheduleStateService', () => { expect((spectator.service as any)._canLoadEvents()).toBe(false); }); + it('should show bookings made by me for others by default', () => { + const settings = spectator.inject(SettingsService) as any; + settings.get.mockImplementation((key: string) => + key === 'app.features' ? ['desks'] : undefined, + ); + const mine = new Booking({ + id: 'mine', + booking_type: 'desk', + user_email: 'me@example.com', + booked_by_email: 'me@example.com', + }); + const for_other = new Booking({ + id: 'other', + booking_type: 'desk', + user_email: 'other@example.com', + booked_by_email: 'me@example.com', + }); + vi.spyOn(spectator.service, 'isBookingForOtherUser').mockImplementation( + (item) => item === for_other, + ); + (spectator.service as any)._desks.set([mine, for_other]); + + expect(spectator.service.filtered_bookings()).toEqual([ + mine, + for_other, + ]); + + spectator.service.toggleBookingsForOthers(); + + expect(spectator.service.filtered_bookings()).toEqual([mine]); + }); + + it('should ignore a range end date that is before the selected date', async () => { + const day = (date: number) => new Date(2026, 9, date, 10).valueOf(); + spectator.service.setOptions({ period: 'range' }); + spectator.service.setDateRange(day(5), day(9)); + expect(spectator.service.end_date()).toBe(day(9)); + + // The landing page moves the date and leaves the range end in place + spectator.service.setDate(day(12)); + + expect(spectator.service.end_date()).toBeNull(); + await spectator.service['_bookingQuery']( + 'desk', + spectator.service.options().period, + spectator.service.date(), + spectator.service.end_date(), + ); + const params = vi.mocked(ts_client.query).mock.lastCall?.[0] + ?.query_params as Record; + expect(params).toMatchObject({ + period_start: getUnixTime(startOfDay(day(12))), + period_end: getUnixTime(endOfDay(day(12))), + }); + }); + + it('should match a multi-day booking on each day that it covers', () => { + const day = (date: number) => new Date(2026, 9, date, 12).valueOf(); + const window = { + date: startOfDay(day(5)).valueOf(), + duration: 3 * 24 * 60, + all_day: true, + }; + + // `date_end` of these items is the end of the first day + for (const item of [new Booking(window), new CalendarEvent(window)]) { + expect(overlapsDay(item, day(4))).toBe(false); + expect(overlapsDay(item, day(5))).toBe(true); + expect(overlapsDay(item, day(6))).toBe(true); + expect(overlapsDay(item, day(7))).toBe(true); + expect(overlapsDay(item, day(8))).toBe(false); + } + + const meeting = new CalendarEvent({ date: day(5), duration: 60 }); + expect(overlapsDay(meeting, day(5))).toBe(true); + expect(overlapsDay(meeting, day(6))).toBe(false); + }); + + it('should identify bookings made by the current user for someone else', () => { + expect( + isBookingForOtherUser( + new Booking({ + booking_type: 'desk', + user_email: 'other@example.com', + booked_by_email: 'me@example.com', + }), + 'me@example.com', + ), + ).toBe(true); + expect( + isBookingForOtherUser( + new Booking({ + booking_type: 'desk', + user_email: 'me@example.com', + booked_by_email: 'me@example.com', + }), + 'me@example.com', + ), + ).toBe(false); + }); + it('should share identical in-flight booking queries', async () => { const date = new Date(2026, 5, 22, 9).valueOf(); @@ -206,6 +501,96 @@ describe('ScheduleStateService', () => { expect(params).toMatchObject({ include_checked_out: true, include_deleted: true, + deleted: true, + }); + }); + + describe('room cancellation', () => { + function confirmRemoval(reason = 'done') { + const close = vi.fn(); + vi.mocked(spectator.inject(MatDialog).open).mockReturnValue({ + afterClosed: () => of({ reason }), + componentInstance: { + event: reason === 'done' ? of({ reason }) : NEVER, + loading: { set: vi.fn() }, + }, + close, + } as unknown as ReturnType); + return close; + } + + function nativeRoom() { + return newCalendarEventFromBooking( + new Booking({ + id: 'room-booking-1', + booking_type: 'room', + asset_id: 'room-1', + user_email: 'staff@example.com', + extension_data: { creator: 'staff@example.com' }, + booking_start: 1800000000, + booking_end: 1800003600, + }), + ); + } + + it.each([true, false])( + 'uses the native API regardless of the current setting %s', + async (use_bookings) => { + confirmRemoval(); + vi.mocked( + spectator.inject(SettingsService).get, + ).mockReturnValue(use_bookings); + const removeItem = vi.spyOn(spectator.service, 'removeItem'); + const booking = nativeRoom(); + + await spectator.service.remove(booking); + + expect(ts_client.del).toHaveBeenCalledExactlyOnceWith( + expect.stringMatching(/\/bookings\/room-booking-1\?/), + { response_type: 'void' }, + ); + expect(ts_client.get).not.toHaveBeenCalled(); + expect(removeItem).toHaveBeenCalledWith(booking); + }, + ); + + it('keeps calendar events on the calendar API', async () => { + confirmRemoval(); + const event = new CalendarEvent({ + id: 'calendar-event-1', + creator: 'staff@example.com', + mailbox: 'staff@example.com', + }); + + await spectator.service.remove(event); + + expect(ts_client.del).toHaveBeenCalledExactlyOnceWith( + expect.stringMatching(/\/events\/calendar-event-1\?/), + { response_type: 'void' }, + ); + }); + + it('does not request an API when confirmation is dismissed', async () => { + confirmRemoval('close'); + + await spectator.service.remove(nativeRoom()); + + expect(ts_client.del).not.toHaveBeenCalled(); + expect(ts_client.get).not.toHaveBeenCalled(); + }); + + it('keeps the booking visible when native cancellation fails', async () => { + const close = confirmRemoval(); + const removeItem = vi.spyOn(spectator.service, 'removeItem'); + const error = new Error('Cancellation failed'); + vi.mocked(ts_client.del).mockRejectedValueOnce(error); + + await expect( + spectator.service.remove(nativeRoom()), + ).rejects.toThrow(error); + + expect(close).toHaveBeenCalled(); + expect(removeItem).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/workplace/src/tests/schedule/schedule-topbar.component.spec.ts b/apps/workplace/src/tests/schedule/schedule-topbar.component.spec.ts new file mode 100644 index 0000000000..21b16c6a59 --- /dev/null +++ b/apps/workplace/src/tests/schedule/schedule-topbar.component.spec.ts @@ -0,0 +1,154 @@ +import { + createRoutingFactory, + SpectatorRouting, +} from '@ngneat/spectator/vitest'; +import { SettingsService } from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { DateRangeCalendarComponent } from '@placeos/form-fields'; +import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; +import { addDays, isSameDay, startOfDay, subDays } from 'date-fns'; +import { ScheduleTopbarComponent } from '../../app/schedule/schedule-topbar.component'; + +describe('ScheduleTopbarComponent', () => { + let spectator: SpectatorRouting; + const settings_get = vi.fn(); + + const fixed_day = startOfDay(new Date('2026-05-14T00:00:00')).valueOf(); + + const createComponent = createRoutingFactory({ + component: ScheduleTopbarComponent, + declarations: [ + MockPipe(TranslatePipe, (key) => key), + MockComponent(IconComponent), + MockComponent(DateRangeCalendarComponent), + ], + providers: [MockProvider(SettingsService, { get: settings_get })], + }); + + beforeEach(() => { + vi.clearAllMocks(); + spectator = createComponent({ props: { date: fixed_day } }); + }); + + it('should create component', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should read the week start offset from settings', () => { + settings_get.mockReturnValue(1); + expect(spectator.component.offset_weekday).toBe(1); + expect(settings_get).toHaveBeenCalledWith('app.week_start'); + }); + + it('should default the week start offset to 0 when unset', () => { + settings_get.mockReturnValue(undefined); + expect(spectator.component.offset_weekday).toBe(0); + }); + + describe('resetDate()', () => { + it('sets the date to today and clears any end date', () => { + spectator.setInput('end_date', addDays(fixed_day, 3).valueOf()); + spectator.component.resetDate(); + expect(isSameDay(spectator.component.date(), Date.now())).toBe(true); + expect(spectator.component.end_date()).toBeNull(); + }); + }); + + describe('setStartDate()', () => { + it('sets the start date and clears the end date', () => { + spectator.setInput('end_date', addDays(fixed_day, 3).valueOf()); + const new_start = addDays(fixed_day, 1).valueOf(); + spectator.component.setStartDate(new_start); + expect(spectator.component.date()).toBe(new_start); + expect(spectator.component.end_date()).toBeNull(); + }); + }); + + describe('previousDate() / nextDate()', () => { + it('steps back a single day in day view', () => { + spectator.setInput('view', 'day'); + spectator.component.previousDate(); + expect(spectator.component.date()).toBe( + subDays(fixed_day, 1).valueOf(), + ); + }); + + it('steps forward a single day in day view', () => { + spectator.setInput('view', 'day'); + spectator.component.nextDate(); + expect(spectator.component.date()).toBe( + addDays(fixed_day, 1).valueOf(), + ); + }); + + it('steps back a full week in week view', () => { + spectator.setInput('view', 'week'); + spectator.component.previousDate(); + expect(spectator.component.date()).toBe( + subDays(fixed_day, 7).valueOf(), + ); + }); + + it('steps forward a full week in week view', () => { + spectator.setInput('view', 'week'); + spectator.component.nextDate(); + expect(spectator.component.date()).toBe( + addDays(fixed_day, 7).valueOf(), + ); + }); + + it('steps by a single day in list view', () => { + spectator.setInput('view', 'list'); + spectator.component.nextDate(); + expect(spectator.component.date()).toBe( + addDays(fixed_day, 1).valueOf(), + ); + }); + }); + + describe('has_date_range()', () => { + it('is false unless the list view has a distinct end date', () => { + spectator.setInput('view', 'day'); + spectator.setInput('end_date', addDays(fixed_day, 3).valueOf()); + expect(spectator.component.has_date_range()).toBe(false); + }); + + it('is false in list view when start and end are the same day', () => { + spectator.setInput('view', 'list'); + spectator.setInput('end_date', fixed_day); + expect(spectator.component.has_date_range()).toBe(false); + }); + + it('is true in list view with a distinct end date', () => { + spectator.setInput('view', 'list'); + spectator.setInput('end_date', addDays(fixed_day, 3).valueOf()); + expect(spectator.component.has_date_range()).toBe(true); + }); + }); + + describe('view navigation', () => { + it('hides the prev/next arrows in list view', () => { + spectator.setInput('view', 'day'); + spectator.detectChanges(); + const day_arrows = spectator.queryAll('icon').length; + expect(day_arrows).toBeGreaterThan(0); + + spectator.setInput('view', 'list'); + spectator.detectChanges(); + // list view swaps the arrows for a single calendar icon + const text = spectator.query('[date]')?.textContent || ''; + expect(text).not.toContain('keyboard_arrow_left'); + }); + + it('updates the active view when a tab is clicked', () => { + spectator.setInput('view', 'list'); + spectator.detectChanges(); + const tabs = spectator.queryAll('[view] a'); + // tabs are ordered day, week, list + spectator.click(tabs[1] as HTMLElement); + expect(spectator.component.view()).toBe('week'); + spectator.click(tabs[0] as HTMLElement); + expect(spectator.component.view()).toBe('day'); + }); + }); +}); diff --git a/apps/workplace/src/tests/schedule/schedule-week-view.component.spec.ts b/apps/workplace/src/tests/schedule/schedule-week-view.component.spec.ts new file mode 100644 index 0000000000..cc5d4baec4 --- /dev/null +++ b/apps/workplace/src/tests/schedule/schedule-week-view.component.spec.ts @@ -0,0 +1,117 @@ +import { MatDialog } from '@angular/material/dialog'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { BookingDetailsModalComponent } from '@placeos/bookings'; +import { Booking, OrganisationService } from '@placeos/common'; +import { MockProvider } from 'ng-mocks'; +import { ScheduleStateService } from '../../app/schedule/schedule-state.service'; +import { ScheduleWeekViewComponent } from '../../app/schedule/schedule-week-view.component'; + +describe('ScheduleWeekViewComponent', () => { + let spectator: Spectator; + const createComponent = createComponentFactory({ + component: ScheduleWeekViewComponent, + providers: [ + MockProvider(MatDialog, { open: vi.fn(), closeAll: vi.fn() }), + MockProvider(OrganisationService, { levelWithID: vi.fn() }), + { + provide: ScheduleStateService, + useValue: { + get offset_weekday() { + return 1; + }, + edit: vi.fn(), + remove: vi.fn(), + editBooking: vi.fn(), + end: vi.fn(), + triggerPoll: vi.fn(), + }, + }, + ], + }); + + beforeEach(() => (spectator = createComponent())); + + it('should create component', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should hide the asset id of unallocated parking bookings', () => { + // asset_name falls back to the raw asset_id + const via_asset_name = new Booking({ + booking_type: 'parking', + asset_id: 'unallocated-123', + }); + // location getter falls back to the description holding the raw id + const via_description = new Booking({ + booking_type: 'parking', + asset_id: 'unallocated-456', + description: 'unallocated-456', + }); + const allocated = new Booking({ + booking_type: 'parking', + asset_id: 'space-1', + asset_name: 'Bay 1', + }); + expect(spectator.component.location(via_asset_name)).toBe(''); + expect(spectator.component.location(via_description)).toBe(''); + expect(spectator.component.location(allocated)).toBe('Bay 1'); + }); + + it('should not show a visitor name for non-visitor bookings', () => { + const parking = new Booking({ + booking_type: 'parking', + asset_id: 'unallocated-5gIZsCGa', + title: 'Parking Request', + }); + expect(spectator.component.visitorName(parking)).toBe(''); + }); + + it('should align displayed weekdays to the configured week start', () => { + spectator.setInput('date', new Date('2026-04-15T12:00:00').valueOf()); + + expect(spectator.component.weekdays().map((day) => day.id)).toEqual([ + '2026-04-13', + '2026-04-14', + '2026-04-15', + '2026-04-16', + '2026-04-17', + '2026-04-18', + '2026-04-19', + ]); + }); + + it('should list a multi-day booking on each day that it covers', () => { + // Monday to Wednesday of a week that starts on Monday + const monday = new Date(2026, 4, 11).valueOf(); + spectator.setInput('date', monday); + spectator.setInput('bookings', [ + new Booking({ + id: 'multi', + booking_type: 'parking', + date: monday, + duration: 3 * 24 * 60, + all_day: true, + }), + ]); + + const by_date = spectator.component.bookings_by_date(); + expect( + spectator.component.weekdays().map((_) => by_date[_.id].length), + ).toEqual([1, 1, 1, 0, 0, 0, 0]); + }); + + it('should refresh bookings after a modal checkout', () => { + const dialog = spectator.inject(MatDialog); + const state = spectator.inject(ScheduleStateService); + spectator.component.viewBooking( + new Booking({ id: 'booking-1', booking_type: 'desk' }), + ); + + expect(dialog.open).toHaveBeenCalledWith( + BookingDetailsModalComponent, + expect.anything(), + ); + (dialog.open as any).mock.calls[0][1].data.refresh_fn(); + expect(state.triggerPoll).toHaveBeenCalled(); + }); +}); diff --git a/apps/workplace/src/tests/schedule/schedule.component.spec.ts b/apps/workplace/src/tests/schedule/schedule.component.spec.ts index 7160c4c507..ef7a25e1ee 100644 --- a/apps/workplace/src/tests/schedule/schedule.component.spec.ts +++ b/apps/workplace/src/tests/schedule/schedule.component.spec.ts @@ -1,287 +1,117 @@ import { signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { MatDialog } from '@angular/material/dialog'; import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { Router } from '@angular/router'; import { createRoutingFactory, SpectatorRouting, } from '@ngneat/spectator/vitest'; -import { BookingCardComponent, BookingFormService } from '@placeos/bookings'; +import { BookingCardComponent } from '@placeos/bookings'; import { Booking, CalendarEvent, SettingsService } from '@placeos/common'; -import { - EventCardComponent, - EventFormService, - newCalendarEventFromBooking, -} from '@placeos/events'; -import * as ts_client from '@placeos/ts-client'; -import { MockComponent, MockProvider } from 'ng-mocks'; -import { NEVER, of } from 'rxjs'; +import { mockComponent } from '@placeos/common/tests'; +import { EventCardComponent } from '@placeos/events'; +import { MockProvider, ngMocks } from 'ng-mocks'; import { FooterMenuComponent } from '../../app/components/footer-menu.component'; import { TopbarComponent } from '../../app/components/topbar.component'; +import { VirtualConciergeButtonComponent } from '../../app/components/virtual-concierge-button.component'; +import { ScheduleDayViewComponent } from '../../app/schedule/schedule-day-view.component'; import { ScheduleFiltersComponent } from '../../app/schedule/schedule-filters.component'; -import { ScheduleMobileCalendarComponent } from '../../app/schedule/schedule-mobile-calendar.component'; +import { ScheduleListViewComponent } from '../../app/schedule/schedule-list-view.component'; import { ScheduleSidebarComponent } from '../../app/schedule/schedule-sidebar.component'; import { ScheduleStateService } from '../../app/schedule/schedule-state.service'; +import { ScheduleTopbarComponent } from '../../app/schedule/schedule-topbar.component'; +import { ScheduleWeekViewComponent } from '../../app/schedule/schedule-week-view.component'; import { ScheduleComponent } from '../../app/schedule/schedule.component'; -// Native vitest cannot module-mock workspace packages. `checkinBooking` -// (from @placeos/bookings) and `openConfirmModal` (from @placeos/components) -// are real functions here; `checkinBooking` calls @placeos/ts-client `post` -// (the only mockable layer) and `openConfirmModal` consumes the injected -// MatDialog, both of which are stubbed below. -vi.mock('@placeos/ts-client', { spy: true }); - describe('ScheduleComponent', () => { let spectator: SpectatorRouting; + const bookings = signal<(Booking | CalendarEvent)[]>([]); const createComponent = createRoutingFactory({ component: ScheduleComponent, declarations: [ - MockComponent(ScheduleSidebarComponent), - MockComponent(ScheduleMobileCalendarComponent), - MockComponent(ScheduleFiltersComponent), - MockComponent(EventCardComponent), - MockComponent(BookingCardComponent), - MockComponent(TopbarComponent), - MockComponent(FooterMenuComponent), + mockComponent(VirtualConciergeButtonComponent), + mockComponent(ScheduleSidebarComponent), + mockComponent(ScheduleFiltersComponent), + mockComponent(EventCardComponent), + mockComponent(BookingCardComponent), + mockComponent(TopbarComponent), + mockComponent(FooterMenuComponent), + mockComponent(ScheduleTopbarComponent), + mockComponent(ScheduleListViewComponent), + mockComponent(ScheduleWeekViewComponent), + mockComponent(ScheduleDayViewComponent), ], providers: [ MockProvider(ScheduleStateService, { - filtered_bookings: signal([]), + bookings, + filtered_bookings: bookings, loading: signal(false), date: signal(0), + end_date: signal(null), toggleType: vi.fn(), setDate: vi.fn(), + setEndDate: vi.fn(), + setOptions: vi.fn(), + startPolling: vi.fn(() => () => {}), getOptions: vi.fn(() => ({ period: 'day' })), - removeItem: vi.fn(), triggerPoll: vi.fn(), } as any), - MockProvider(EventFormService, { newForm: vi.fn() }), - MockProvider(BookingFormService, { - newForm: vi.fn(), - model: Object.assign( - vi.fn(() => ({})), - { - set: vi.fn(), - update: vi.fn(), - }, - ), - } as any), - MockProvider(Router, { navigate: vi.fn() }), - MockProvider(MatDialog, { open: vi.fn(), closeAll: vi.fn() }), MockProvider(SettingsService, { get: vi.fn() }), ], imports: [MatProgressBarModule, FormsModule], }); beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(ts_client.post).mockResolvedValue({} as any); - vi.mocked(ts_client.del).mockResolvedValue(undefined); + bookings.set([]); spectator = createComponent(); }); - afterEach(() => { - vi.useRealTimers(); - }); - - it('should refresh bookings on request', () => { - const state = spectator.inject(ScheduleStateService); - - spectator.component.refresh_fn(); - - expect(state.triggerPoll).toHaveBeenCalled(); - }); - - it('should not patch resources when editing visitor bookings', () => { - vi.useFakeTimers(); - const booking_form = spectator.inject(BookingFormService); - const booking = new Booking({ - booking_type: 'visitor', - type: 'visitor', - asset_id: 'visitor@example.com', - asset_name: 'Visitor', - }); - - spectator.component.editBooking(booking); - vi.runAllTimers(); - - expect(booking_form.newForm).toHaveBeenCalledWith('visitor', booking); - expect(booking_form.model.update).not.toHaveBeenCalled(); - vi.useRealTimers(); - }); - - it('should patch resources when editing non-visitor bookings', () => { - vi.useFakeTimers(); - const booking_form = spectator.inject(BookingFormService); - const booking = new Booking({ - booking_type: 'desk', - type: 'desk', - asset_id: 'desk-1', - asset_name: 'Desk 1', - }); - - spectator.component.editBooking(booking); - vi.runAllTimers(); - - expect(booking_form.model.update).toHaveBeenCalled(); - const updater = (booking_form.model.update as any).mock.calls[0][0]; - expect(updater({})).toEqual({ - resources: [{ id: 'desk-1', name: 'Desk 1' }], - asset_id: 'desk-1', - }); - vi.useRealTimers(); - }); - - it('should refresh ended bookings without hiding them as deleted', async () => { + it('should reset the shared period and range end when destroyed', () => { const state = spectator.inject(ScheduleStateService); - // The component imports MatDialogModule, so its injector shadows the - // TestBed-level MatDialog mock — spy on the instance it actually gets. - const dialog = spectator.fixture.debugElement.injector.get(MatDialog); - // Shape returned so the real `openConfirmModal` resolves with reason 'done'. - vi.spyOn(dialog, 'open').mockReturnValue({ - afterClosed: () => of({ reason: 'done' }), - componentInstance: { - event: of({ reason: 'done' }), - loading: { set: vi.fn() }, - }, - close: vi.fn(), - } as any); - const booking = new Booking({ - id: 'booking-1', - booking_type: 'desk', - type: 'desk', - asset_id: 'desk-1', - asset_name: 'Desk 1', - }); + vi.mocked(state.setOptions).mockClear(); + vi.mocked(state.setEndDate).mockClear(); - await spectator.component.end(booking); + spectator.fixture.destroy(); - // `checkinBooking` (workspace fn) cannot be spied; assert the underlying - // ts-client POST it issues to the booking check-in endpoint instead. - expect(ts_client.post).toHaveBeenCalled(); - const [url] = vi.mocked(ts_client.post).mock.calls[0]; - expect(url).toContain('booking-1'); - expect(url).toContain('check_in'); - expect(state.triggerPoll).toHaveBeenCalled(); - expect(state.removeItem).not.toHaveBeenCalled(); + expect(state.setOptions).toHaveBeenCalledWith({ period: 'day' }); + expect(state.setEndDate).toHaveBeenCalledWith(null); }); - it('should refresh cancelled bookings without hiding them as deleted', async () => { - const state = spectator.inject(ScheduleStateService); - const dialog = spectator.fixture.debugElement.injector.get(MatDialog); - vi.spyOn(dialog, 'open').mockReturnValue({ - afterClosed: () => of({ reason: 'done' }), - componentInstance: { - event: of({ reason: 'done' }), - loading: { set: vi.fn() }, - }, - close: vi.fn(), - } as any); - const booking = new Booking({ - id: 'booking-1', - booking_type: 'visitor', - type: 'visitor', - asset_id: 'visitor@example.com', - asset_name: 'Visitor', - }); - - await spectator.component.remove(booking); - - expect(ts_client.del).toHaveBeenCalled(); - expect(state.triggerPoll).toHaveBeenCalled(); - expect(state.removeItem).not.toHaveBeenCalled(); - }); - describe('room cancellation', () => { - function confirmRemoval(reason = 'done') { - const dialog = - spectator.fixture.debugElement.injector.get(MatDialog); - const close = vi.fn(); - vi.spyOn(dialog, 'open').mockReturnValue({ - afterClosed: () => of({ reason }), - componentInstance: { - event: reason === 'done' ? of({ reason }) : NEVER, - loading: { set: vi.fn() }, - }, - close, - } as unknown as ReturnType); - return close; - } - - function nativeRoom() { - return newCalendarEventFromBooking( - new Booking({ - id: 'room-booking-1', - booking_type: 'room', - asset_id: 'room-1', - user_email: 'staff@example.com', - extension_data: { creator: 'staff@example.com' }, - booking_start: 1800000000, - booking_end: 1800003600, - }), - ); - } - - it.each([true, false])( - 'uses the native API regardless of the current setting %s', - async (use_bookings) => { - confirmRemoval(); - vi.mocked( - spectator.inject(SettingsService).get, - ).mockReturnValue(use_bookings); - const booking = nativeRoom(); - - await spectator.component.remove(booking); - - expect(ts_client.del).toHaveBeenCalledExactlyOnceWith( - expect.stringMatching(/\/bookings\/room-booking-1\?/), - { response_type: 'void' }, + it.each(['day', 'week'] as const)( + 'removes cancelled parking from the %s calendar after refresh and retains list history', + async (view) => { + const active = new Booking({ + id: 'parking-1', + booking_type: 'parking', + }); + const meeting = new CalendarEvent({ id: 'meeting-1' }); + bookings.set([active, meeting]); + spectator.component.view.set(view); + await spectator.fixture.whenStable(); + const calendar = () => + ngMocks.input( + view === 'day' ? 'schedule-day-view' : 'schedule-week-view', + 'bookings', ); - expect(ts_client.get).not.toHaveBeenCalled(); - expect( - spectator.inject(ScheduleStateService).removeItem, - ).toHaveBeenCalledWith(booking); - }, - ); + expect(calendar()).toEqual([active, meeting]); - it('keeps calendar events on the calendar API', async () => { - confirmRemoval(); - const event = new CalendarEvent({ - id: 'calendar-event-1', - creator: 'staff@example.com', - mailbox: 'staff@example.com', + const cancelled = new Booking({ ...active, deleted: true }); + const status_cancelled = new Booking({ + id: 'parking-2', + booking_type: 'parking', + status: 'cancelled', }); - - await spectator.component.remove(event); - - expect(ts_client.del).toHaveBeenCalledExactlyOnceWith( - expect.stringMatching(/\/events\/calendar-event-1\?/), - { response_type: 'void' }, - ); - }); - - it('does not request an API when confirmation is dismissed', async () => { - confirmRemoval('close'); - - await spectator.component.remove(nativeRoom()); - - expect(ts_client.del).not.toHaveBeenCalled(); - expect(ts_client.get).not.toHaveBeenCalled(); - }); - - it('keeps the booking visible when native cancellation fails', async () => { - const close = confirmRemoval(); - const error = new Error('Cancellation failed'); - vi.mocked(ts_client.del).mockRejectedValueOnce(error); - - await expect( - spectator.component.remove(nativeRoom()), - ).rejects.toThrow(error); - - expect(close).toHaveBeenCalled(); - expect( - spectator.inject(ScheduleStateService).removeItem, - ).not.toHaveBeenCalled(); - }); - }); + bookings.set([cancelled, status_cancelled, meeting]); + await spectator.fixture.whenStable(); + expect(calendar()).toEqual([meeting]); + + spectator.component.view.set('list'); + await spectator.fixture.whenStable(); + expect(ngMocks.input('schedule-list-view', 'bookings')).toEqual([ + cancelled, + status_cancelled, + meeting, + ]); + expect(cancelled.status).toBe('cancelled'); + }, + ); }); diff --git a/apps/workplace/src/tests/team-schedule/common.spec.ts b/apps/workplace/src/tests/team-schedule/common.spec.ts new file mode 100644 index 0000000000..0f1cb380cd --- /dev/null +++ b/apps/workplace/src/tests/team-schedule/common.spec.ts @@ -0,0 +1,36 @@ +import { LocationStatus, USER_LOCATIONS } from '../../app/team-schedule/common'; + +describe('team-schedule/common', () => { + const expected_keys: LocationStatus[] = [ + 'unspecified', + 'wfh', + 'office', + 'onsite', + 'on_leave', + 'off_sick', + ]; + + it('should define styling metadata for every location status', () => { + expect(Object.keys(USER_LOCATIONS).sort()).toEqual( + [...expected_keys].sort(), + ); + }); + + it('should provide a name, icon and colours for each status', () => { + for (const key of expected_keys) { + const entry = USER_LOCATIONS[key]; + expect(typeof entry.name).toBe('string'); + expect(entry.name.length).toBeGreaterThan(0); + expect(typeof entry.icon).toBe('string'); + expect(entry.icon.length).toBeGreaterThan(0); + expect(entry.fg_color).toMatch(/^rgb\(/); + expect(entry.bg_color).toMatch(/^rgb\(/); + } + }); + + it('should map office status to a business icon', () => { + expect(USER_LOCATIONS.office.icon).toBe('business'); + expect(USER_LOCATIONS.wfh.icon).toBe('home'); + expect(USER_LOCATIONS.off_sick.icon).toBe('local_hospital'); + }); +}); diff --git a/apps/workplace/src/tests/team-schedule/team-quick-actions.component.spec.ts b/apps/workplace/src/tests/team-schedule/team-quick-actions.component.spec.ts new file mode 100644 index 0000000000..2b50a3c407 --- /dev/null +++ b/apps/workplace/src/tests/team-schedule/team-quick-actions.component.spec.ts @@ -0,0 +1,125 @@ +import { signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { MockProvider } from 'ng-mocks'; +import { BookingFormService } from '@placeos/bookings'; +import { setCurrentUser } from '@placeos/common'; +import { TeamQuickActionsComponent } from '../../app/team-schedule/team-quick-actions.component'; +import { TeamScheduleService } from '../../app/team-schedule/team-schedule.service'; + +describe('TeamQuickActionsComponent', () => { + let spectator: Spectator; + const select_mode = signal(false); + const selected_count = signal(0); + const set_options = vi.fn(); + const navigate = vi.fn(); + const clear_selection = vi.fn(); + const toggle_select_mode = vi.fn(() => select_mode.set(!select_mode())); + const selected_members = vi.fn(() => [] as any[]); + const get_team_members = vi.fn(() => [] as any[]); + + const createComponent = createComponentFactory({ + component: TeamQuickActionsComponent, + providers: [ + MockProvider(MatDialog, { open: vi.fn() }), + MockProvider(Router, { navigate } as any), + MockProvider(BookingFormService, { setOptions: set_options } as any), + MockProvider(TeamScheduleService, { + select_mode, + selected_count, + selected_members, + getTeamMembers: get_team_members, + clearSelection: clear_selection, + toggleSelectMode: toggle_select_mode, + } as any), + ], + detectChanges: false, + }); + + beforeEach(() => { + [ + set_options, + navigate, + clear_selection, + toggle_select_mode, + selected_members, + get_team_members, + ].forEach((fn) => fn.mockClear()); + setCurrentUser({ + id: 'me', + email: 'me@example.com', + } as any); + selected_members.mockReturnValue([]); + get_team_members.mockReturnValue([]); + select_mode.set(false); + selected_count.set(0); + spectator = createComponent(); + }); + + it('should enter select mode when not already selecting', () => { + spectator.component.toggleSelectMode(); + expect(toggle_select_mode).toHaveBeenCalled(); + expect(clear_selection).not.toHaveBeenCalled(); + }); + + it('should clear selection when already in select mode', () => { + select_mode.set(true); + spectator.component.toggleSelectMode(); + expect(clear_selection).toHaveBeenCalled(); + expect(toggle_select_mode).not.toHaveBeenCalled(); + }); + + it('should book for the whole team when nothing is selected', () => { + get_team_members.mockReturnValue([ + { user: { email: 'a@example.com', name: 'A' } } as any, + ]); + spectator.component.bookForGroup(); + + expect(get_team_members).toHaveBeenCalled(); + const options = set_options.mock.calls[0][0]; + expect(options.type).toBe('desk'); + expect(options.group).toBe(true); + // current user is prepended + expect(options.members.map((m: any) => m.email)).toEqual([ + 'me@example.com', + 'a@example.com', + ]); + expect(clear_selection).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(['/book', 'desks']); + }); + + it('should book for the selected members when selecting', () => { + select_mode.set(true); + selected_count.set(2); + selected_members.mockReturnValue([ + { user: { email: 'x@example.com', name: 'X' } } as any, + { user: { email: 'y@example.com', name: 'Y' } } as any, + ]); + spectator.component.bookForGroup(); + + expect(selected_members).toHaveBeenCalled(); + expect(get_team_members).not.toHaveBeenCalled(); + const options = set_options.mock.calls[0][0]; + expect(options.members.map((m: any) => m.email)).toEqual([ + 'me@example.com', + 'x@example.com', + 'y@example.com', + ]); + }); + + it('should not duplicate the current user if already in the group', () => { + setCurrentUser({ + id: 'me', + email: 'me@example.com', + } as any); + get_team_members.mockReturnValue([ + { user: { email: 'me@example.com', name: 'Me' } } as any, + ]); + spectator.component.bookForGroup(); + const options = set_options.mock.calls[0][0]; + expect( + options.members.filter((m: any) => m.email === 'me@example.com'), + ).toHaveLength(1); + }); +}); diff --git a/apps/workplace/src/tests/team-schedule/team-schedule-filters.component.spec.ts b/apps/workplace/src/tests/team-schedule/team-schedule-filters.component.spec.ts new file mode 100644 index 0000000000..0e3529ed1e --- /dev/null +++ b/apps/workplace/src/tests/team-schedule/team-schedule-filters.component.spec.ts @@ -0,0 +1,84 @@ +import { signal } from '@angular/core'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { MockProvider } from 'ng-mocks'; +import { Subject } from 'rxjs'; +import { TeamScheduleFiltersComponent } from '../../app/team-schedule/team-schedule-filters.component'; +import { TeamScheduleService } from '../../app/team-schedule/team-schedule.service'; + +describe('TeamScheduleFiltersComponent', () => { + let spectator: Spectator; + const query_params = new Subject(); + const set_filters = vi.fn(); + const previous_week = vi.fn(); + const next_week = vi.fn(); + + const createComponent = createComponentFactory({ + component: TeamScheduleFiltersComponent, + providers: [ + MockProvider(ActivatedRoute, { + queryParamMap: query_params.asObservable(), + } as any), + MockProvider(TeamScheduleService, { + filters: signal({ + date: Date.now(), + day_index: -1, + view: 'all', + search: '', + department: null, + office: null, + status: null, + }), + departments: signal(['Sales']), + offices: signal(['Alpha']), + statuses: signal(['office']), + setFilters: set_filters, + previousWeek: previous_week, + nextWeek: next_week, + } as any), + ], + detectChanges: false, + }); + + beforeEach(() => { + vi.clearAllMocks(); + spectator = createComponent(); + }); + + it('should expose the service filter option signals', () => { + expect(spectator.component.departments()).toEqual(['Sales']); + expect(spectator.component.offices()).toEqual(['Alpha']); + expect(spectator.component.statuses()).toEqual(['office']); + }); + + it('should delegate filter updates to the service', () => { + spectator.component.setFilters({ search: 'abc' }); + expect(set_filters).toHaveBeenCalledWith({ search: 'abc' }); + }); + + it('should delegate week navigation to the service', () => { + spectator.component.previousWeek(); + spectator.component.nextWeek(); + expect(previous_week).toHaveBeenCalled(); + expect(next_week).toHaveBeenCalled(); + }); + + it('should reset to today with an auto day index', () => { + spectator.component.resetToToday(); + expect(set_filters).toHaveBeenCalledWith( + expect.objectContaining({ day_index: -1 }), + ); + }); + + it('should apply the view query param on init', () => { + spectator.component.ngOnInit(); + query_params.next(convertToParamMap({ view: 'favorites' })); + expect(set_filters).toHaveBeenCalledWith({ view: 'favorites' }); + }); + + it('should ignore query param changes without a view', () => { + spectator.component.ngOnInit(); + query_params.next(convertToParamMap({ other: 'x' })); + expect(set_filters).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/workplace/src/tests/team-schedule/team-schedule-list.component.spec.ts b/apps/workplace/src/tests/team-schedule/team-schedule-list.component.spec.ts new file mode 100644 index 0000000000..44eee786a7 --- /dev/null +++ b/apps/workplace/src/tests/team-schedule/team-schedule-list.component.spec.ts @@ -0,0 +1,152 @@ +import { signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { MockProvider } from 'ng-mocks'; +import { Subject } from 'rxjs'; +import { TeamMember } from '../../app/team-schedule/common'; +import { TeamScheduleListComponent } from '../../app/team-schedule/team-schedule-list.component'; +import { TeamScheduleService } from '../../app/team-schedule/team-schedule.service'; + +const makeMember = (overrides: Partial = {}): TeamMember => + ({ + user: { id: 'u1', name: 'User', email: 'u1@x' }, + is_favorite: false, + is_my_team: false, + statuses: [], + ...overrides, + }) as TeamMember; + +describe('TeamScheduleListComponent', () => { + let spectator: Spectator; + const week_days = signal([ + { date: 1000, is_today: false }, + { date: 2000, is_today: true }, + ]); + const active_day_index = signal(1); + const set_filters = vi.fn(); + const toggle_selection = vi.fn(); + const is_selected = vi.fn(() => true); + const toggle_favorite = vi.fn(); + const get_status = vi.fn(); + const get_style = vi.fn(() => ({ icon: 'home' }) as any); + + const component_instance = { + show_close: { set: vi.fn() }, + date: { set: vi.fn() }, + nearby_desk_id: { set: vi.fn() }, + level_id: { set: vi.fn() }, + duration: { set: vi.fn() }, + }; + let closed: Subject; + const refresh = vi.fn(); + const dialog_open = vi.fn(() => ({ + componentInstance: component_instance, + afterClosed: () => closed, + })); + + const createComponent = createComponentFactory({ + component: TeamScheduleListComponent, + providers: [ + MockProvider(MatDialog, { open: dialog_open } as any), + MockProvider(TeamScheduleService, { + week_days, + active_day_index, + filtered_members: signal([]), + total_members: signal(0), + select_mode: signal(false), + loading: signal(false), + refresh, + setFilters: set_filters, + toggleMemberSelection: toggle_selection, + isMemberSelected: is_selected, + toggleFavorite: toggle_favorite, + getMemberStatus: get_status, + getLocationStyle: get_style, + } as any), + ], + detectChanges: false, + }); + + beforeEach(() => { + vi.clearAllMocks(); + closed = new Subject(); + active_day_index.set(1); + spectator = createComponent(); + }); + + it('should set the selected day index on the service', () => { + spectator.component.setDayIndex(3); + expect(set_filters).toHaveBeenCalledWith({ day_index: 3 }); + }); + + it('should delegate member actions to the service', () => { + const member = makeMember(); + spectator.component.toggleMemberSelection(member); + expect(toggle_selection).toHaveBeenCalledWith(member); + expect(spectator.component.isMemberSelected(member)).toBe(true); + spectator.component.toggleFavorite(member); + expect(toggle_favorite).toHaveBeenCalledWith(member); + spectator.component.getMemberStatus(member); + expect(get_status).toHaveBeenCalledWith(member); + spectator.component.getLocationStyle('wfh'); + expect(get_style).toHaveBeenCalledWith('wfh'); + }); + + it('should open the desk modal for the active day with booking details', () => { + const member = makeMember({ + statuses: [ + { date: 1000, status: 'wfh' }, + { + date: 2000, + status: 'office', + booking: { + building_name: 'B', + desk_code: 'D1', + desk_id: 'desk-1', + level_id: 'lvl-1', + duration: 480, + }, + }, + ], + }); + spectator.component.bookNearby(member); + + expect(dialog_open).toHaveBeenCalled(); + expect(component_instance.show_close.set).toHaveBeenCalledWith(true); + // active_day_index is 1 -> uses week_days[1].date + expect(component_instance.date.set).toHaveBeenCalledWith(2000); + expect(component_instance.nearby_desk_id.set).toHaveBeenCalledWith( + 'desk-1', + ); + expect(component_instance.level_id.set).toHaveBeenCalledWith('lvl-1'); + expect(component_instance.duration.set).toHaveBeenCalledWith(480); + }); + + it('should open the desk modal without desk details when there is no booking', () => { + const member = makeMember({ + statuses: [ + { date: 1000, status: 'wfh' }, + { date: 2000, status: 'wfh' }, + ], + }); + spectator.component.bookNearby(member); + + expect(component_instance.date.set).toHaveBeenCalledWith(2000); + expect(component_instance.nearby_desk_id.set).not.toHaveBeenCalled(); + expect(component_instance.level_id.set).not.toHaveBeenCalled(); + }); + + it('should open the add colleagues modal', () => { + spectator.component.openAddColleaguesModal(); + expect(dialog_open).toHaveBeenCalled(); + }); + + it('should reload the colleagues only when the modal added some', () => { + spectator.component.openAddColleaguesModal(); + closed.next(false); + expect(refresh).not.toHaveBeenCalled(); + + closed.next(true); + expect(refresh).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/workplace/src/tests/team-schedule/team-schedule-table.component.spec.ts b/apps/workplace/src/tests/team-schedule/team-schedule-table.component.spec.ts new file mode 100644 index 0000000000..8ab7d6f99b --- /dev/null +++ b/apps/workplace/src/tests/team-schedule/team-schedule-table.component.spec.ts @@ -0,0 +1,145 @@ +import { signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { MockProvider } from 'ng-mocks'; +import { Subject } from 'rxjs'; +import { TeamMember } from '../../app/team-schedule/common'; +import { TeamScheduleTableComponent } from '../../app/team-schedule/team-schedule-table.component'; +import { TeamScheduleService } from '../../app/team-schedule/team-schedule.service'; + +const makeMember = (overrides: Partial = {}): TeamMember => + ({ + user: { id: 'u1', name: 'User', email: 'u1@x' }, + is_favorite: false, + is_my_team: false, + statuses: [], + ...overrides, + }) as TeamMember; + +describe('TeamScheduleTableComponent', () => { + let spectator: Spectator; + const week_days = signal([ + { date: 1000, is_today: false }, + { date: 2000, is_today: true }, + ]); + const toggle_selection = vi.fn(); + const is_selected = vi.fn(() => false); + const toggle_favorite = vi.fn(); + const get_style = vi.fn(() => ({ icon: 'home' }) as any); + + const component_instance = { + show_close: { set: vi.fn() }, + date: { set: vi.fn() }, + nearby_desk_id: { set: vi.fn() }, + level_id: { set: vi.fn() }, + duration: { set: vi.fn() }, + }; + let closed: Subject; + const refresh = vi.fn(); + const dialog_open = vi.fn(() => ({ + componentInstance: component_instance, + afterClosed: () => closed, + })); + + const createComponent = createComponentFactory({ + component: TeamScheduleTableComponent, + providers: [ + MockProvider(MatDialog, { open: dialog_open } as any), + MockProvider(TeamScheduleService, { + week_days, + filtered_members: signal([]), + total_members: signal(0), + booked_count: signal(0), + select_mode: signal(false), + loading: signal(false), + refresh, + toggleMemberSelection: toggle_selection, + isMemberSelected: is_selected, + toggleFavorite: toggle_favorite, + getLocationStyle: get_style, + } as any), + ], + detectChanges: false, + }); + + beforeEach(() => { + vi.clearAllMocks(); + closed = new Subject(); + spectator = createComponent(); + }); + + it('should build a legend entry for every known location', () => { + expect(spectator.component.user_locations.length).toBe(6); + expect( + spectator.component.user_locations.map((l) => l.key), + ).toContain('office'); + }); + + it('should delegate member actions to the service', () => { + const member = makeMember(); + spectator.component.toggleMemberSelection(member); + expect(toggle_selection).toHaveBeenCalledWith(member); + spectator.component.isMemberSelected(member); + expect(is_selected).toHaveBeenCalledWith(member); + spectator.component.toggleFavorite(member); + expect(toggle_favorite).toHaveBeenCalledWith(member); + spectator.component.getLocationStyle('office'); + expect(get_style).toHaveBeenCalledWith('office'); + }); + + it('should open the desk modal for the clicked day with booking details', () => { + const member = makeMember({ + statuses: [ + { date: 1000, status: 'wfh' }, + { + date: 2000, + status: 'office', + booking: { + building_name: 'B', + desk_code: 'D1', + desk_id: 'desk-9', + level_id: 'lvl-9', + duration: 240, + }, + }, + ], + }); + // day with date 2000 maps to index 1 in week_days + spectator.component.bookNearby(member, { date: 2000 }); + + expect(component_instance.show_close.set).toHaveBeenCalledWith(true); + expect(component_instance.date.set).toHaveBeenCalledWith(2000); + expect(component_instance.nearby_desk_id.set).toHaveBeenCalledWith( + 'desk-9', + ); + expect(component_instance.level_id.set).toHaveBeenCalledWith('lvl-9'); + expect(component_instance.duration.set).toHaveBeenCalledWith(240); + }); + + it('should not set desk details when the clicked day has no booking', () => { + const member = makeMember({ + statuses: [ + { date: 1000, status: 'wfh' }, + { date: 2000, status: 'wfh' }, + ], + }); + spectator.component.bookNearby(member, { date: 1000 }); + + expect(component_instance.date.set).toHaveBeenCalledWith(1000); + expect(component_instance.nearby_desk_id.set).not.toHaveBeenCalled(); + }); + + it('should open the add colleagues modal', () => { + spectator.component.openAddColleaguesModal(); + expect(dialog_open).toHaveBeenCalled(); + }); + + it('should reload the colleagues only when the modal added some', () => { + spectator.component.openAddColleaguesModal(); + closed.next(false); + expect(refresh).not.toHaveBeenCalled(); + + closed.next(true); + expect(refresh).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/workplace/src/tests/team-schedule/team-schedule.component.spec.ts b/apps/workplace/src/tests/team-schedule/team-schedule.component.spec.ts new file mode 100644 index 0000000000..35fcbd7986 --- /dev/null +++ b/apps/workplace/src/tests/team-schedule/team-schedule.component.spec.ts @@ -0,0 +1,50 @@ +import { signal } from '@angular/core'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { MockComponent, MockProvider } from 'ng-mocks'; +import { FooterMenuComponent } from '../../app/components/footer-menu.component'; +import { TopbarComponent } from '../../app/components/topbar.component'; +import { VirtualConciergeButtonComponent } from '../../app/components/virtual-concierge-button.component'; +import { TeamQuickActionsComponent } from '../../app/team-schedule/team-quick-actions.component'; +import { TeamScheduleComponent } from '../../app/team-schedule/team-schedule.component'; +import { TeamScheduleFiltersComponent } from '../../app/team-schedule/team-schedule-filters.component'; +import { TeamScheduleListComponent } from '../../app/team-schedule/team-schedule-list.component'; +import { TeamScheduleTableComponent } from '../../app/team-schedule/team-schedule-table.component'; +import { TeamScheduleService } from '../../app/team-schedule/team-schedule.service'; + +describe('TeamScheduleComponent', () => { + let spectator: Spectator; + const createComponent = createComponentFactory({ + component: TeamScheduleComponent, + declarations: [ + MockComponent(TopbarComponent), + MockComponent(FooterMenuComponent), + MockComponent(VirtualConciergeButtonComponent), + MockComponent(TeamQuickActionsComponent), + MockComponent(TeamScheduleFiltersComponent), + MockComponent(TeamScheduleTableComponent), + MockComponent(TeamScheduleListComponent), + ], + providers: [MockProvider(TeamScheduleService, {} as any)], + }); + + beforeEach(() => (spectator = createComponent())); + + it('should default to showing the navigation chrome', () => { + expect(spectator.component.hide_nav).toBe(false); + expect(spectator.query(TopbarComponent)).toBeTruthy(); + expect(spectator.query(FooterMenuComponent)).toBeTruthy(); + }); + + it('should hide the topbar and footer when nav is hidden', () => { + localStorage.setItem('PlaceOS.hide_nav', 'true'); + spectator.detectChanges(); + expect(spectator.query(TopbarComponent)).toBeFalsy(); + expect(spectator.query(FooterMenuComponent)).toBeFalsy(); + localStorage.removeItem('PlaceOS.hide_nav'); + }); + + it('should always render the schedule table and list', () => { + expect(spectator.query(TeamScheduleTableComponent)).toBeTruthy(); + expect(spectator.query(TeamScheduleListComponent)).toBeTruthy(); + }); +}); diff --git a/apps/workplace/src/tests/team-schedule/team-schedule.service.spec.ts b/apps/workplace/src/tests/team-schedule/team-schedule.service.spec.ts new file mode 100644 index 0000000000..aa98a5ce35 --- /dev/null +++ b/apps/workplace/src/tests/team-schedule/team-schedule.service.spec.ts @@ -0,0 +1,666 @@ +import { TestBed } from '@angular/core/testing'; +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest'; +import { MockProvider } from 'ng-mocks'; +import { + addDays, + addWeeks, + format, + startOfWeek, + subWeeks, +} from 'date-fns'; + +vi.mock('@placeos/ts-client', { spy: true }); + +import { + OrganisationService, + setCurrentUser, + SettingsService, + StaffUser, +} from '@placeos/common'; +import { get, showMetadata, showUser } from '@placeos/ts-client'; +import { TeamScheduleService } from '../../app/team-schedule/team-schedule.service'; +import { TeamMember } from '../../app/team-schedule/common'; + +const wait = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms)); + +const makeMember = (overrides: Partial = {}): TeamMember => ({ + user: { + id: overrides.user?.id || 'u1', + name: overrides.user?.name || 'User One', + email: overrides.user?.email || 'u1@example.com', + } as any, + is_favorite: false, + is_my_team: false, + department: 'Engineering', + current_building: 'Building A', + current_desk: 'D1', + statuses: [{ date: Date.now(), status: 'wfh' }], + ...overrides, +}); + +describe('TeamScheduleService', () => { + let spectator: SpectatorService; + let service: TeamScheduleService; + let settings_store: Record; + const save_setting = vi.fn( + (key: string, value: any) => (settings_store[key] = value), + ); + + const createService = createServiceFactory({ + service: TeamScheduleService, + providers: [ + MockProvider(OrganisationService, { + waitUntilInitialised: vi.fn(() => Promise.resolve()), + building: null, + buildings: [ + { + id: 'zone-bld', + name: 'Building One', + display_name: 'Building One (HQ)', + }, + ], + levels: [{ id: 'zone-lvl' }], + } as any), + MockProvider(SettingsService, { + get: vi.fn((key: string) => settings_store[key]), + saveUserSetting: save_setting, + } as any), + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + settings_store = {}; + setCurrentUser({ id: 'me', email: 'me@example.com' } as any); + (showMetadata as any).mockResolvedValue({ details: [] }); + (showUser as any).mockImplementation((email: string) => + Promise.resolve({ email }), + ); + vi.mocked(get).mockResolvedValue([] as any); + spectator = createService(); + service = spectator.service; + }); + + const set_members = (members: TeamMember[]) => + (service as any)._team_members.set(members); + + it('should compute the working week as five week days', () => { + const days = service.week_days(); + expect(days).toHaveLength(5); + const start = startOfWeek(service.filters().date, { weekStartsOn: 1 }); + expect(days[0].date).toBe(start.valueOf()); + expect(days[4].date).toBe(addDays(start, 4).valueOf()); + }); + + describe('active_day_index', () => { + it('should use the manually selected index when set', () => { + service.setFilters({ day_index: 3 }); + expect(service.active_day_index()).toBe(3); + }); + + it('should auto-select today when it falls within the week', () => { + // Force the week to contain today by using the default (current) date + service.setFilters({ date: Date.now(), day_index: -1 }); + const days = service.week_days(); + const today = days.findIndex((d) => d.is_today); + expect(service.active_day_index()).toBe(today >= 0 ? today : 0); + }); + + it('should fall back to index 0 when today is not in the week', () => { + // Use a week well in the past so no day is today + service.setFilters({ + date: subWeeks(Date.now(), 10).valueOf(), + day_index: -1, + }); + expect(service.active_day_index()).toBe(0); + }); + }); + + describe('derived filter options', () => { + beforeEach(() => { + set_members([ + makeMember({ + user: { id: 'a', name: 'A', email: 'a@x' } as any, + department: 'Sales', + current_building: 'Beta', + }), + makeMember({ + user: { id: 'b', name: 'B', email: 'b@x' } as any, + department: 'Admin', + current_building: 'Alpha', + }), + makeMember({ + user: { id: 'c', name: 'C', email: 'c@x' } as any, + department: 'Admin', + current_building: undefined, + }), + ]); + }); + + it('should list unique sorted departments', () => { + expect(service.departments()).toEqual(['Admin', 'Sales']); + }); + + it('should list unique sorted offices ignoring empty buildings', () => { + expect(service.offices()).toEqual(['Alpha', 'Beta']); + }); + + it('should expose all known location statuses', () => { + expect(service.statuses()).toContain('office'); + expect(service.statuses()).toContain('wfh'); + expect(service.statuses()).toHaveLength(6); + }); + }); + + describe('filtered_members', () => { + beforeEach(() => { + service.setFilters({ day_index: 0 }); + set_members([ + makeMember({ + user: { id: 'a', name: 'Alice', email: 'alice@x' } as any, + is_favorite: true, + is_my_team: false, + department: 'Sales', + current_building: 'Beta', + statuses: [{ date: Date.now(), status: 'office' }], + }), + makeMember({ + user: { id: 'b', name: 'Bob', email: 'bob@x' } as any, + is_favorite: false, + is_my_team: true, + department: 'Admin', + current_building: 'Alpha', + statuses: [{ date: Date.now(), status: 'wfh' }], + }), + ]); + }); + + it('should return every member for the "all" view', () => { + service.setFilters({ view: 'all' }); + expect(service.filtered_members().map((m) => m.user.id)).toEqual([ + 'a', + 'b', + ]); + }); + + it('should keep only favourites for the "favorites" view', () => { + service.setFilters({ view: 'favorites' }); + expect(service.filtered_members().map((m) => m.user.id)).toEqual([ + 'a', + ]); + }); + + it('should keep only team members for the "team" view', () => { + service.setFilters({ view: 'team' }); + expect(service.filtered_members().map((m) => m.user.id)).toEqual([ + 'b', + ]); + }); + + it('should match search against name, email and department', () => { + service.setFilters({ search: 'alice' }); + expect(service.filtered_members().map((m) => m.user.id)).toEqual([ + 'a', + ]); + service.setFilters({ search: 'bob@x' }); + expect(service.filtered_members().map((m) => m.user.id)).toEqual([ + 'b', + ]); + service.setFilters({ search: 'admin' }); + expect(service.filtered_members().map((m) => m.user.id)).toEqual([ + 'b', + ]); + }); + + it('should filter by department', () => { + service.setFilters({ department: 'Sales' }); + expect(service.filtered_members().map((m) => m.user.id)).toEqual([ + 'a', + ]); + }); + + it('should filter by office', () => { + service.setFilters({ office: 'Alpha' }); + expect(service.filtered_members().map((m) => m.user.id)).toEqual([ + 'b', + ]); + }); + + it('should filter by status on the active day', () => { + service.setFilters({ status: 'office' }); + expect(service.filtered_members().map((m) => m.user.id)).toEqual([ + 'a', + ]); + }); + }); + + it('should count total and office-booked members for the active day', () => { + service.setFilters({ day_index: 0 }); + set_members([ + makeMember({ + user: { id: 'a', name: 'A', email: 'a@x' } as any, + statuses: [{ date: Date.now(), status: 'office' }], + }), + makeMember({ + user: { id: 'b', name: 'B', email: 'b@x' } as any, + statuses: [{ date: Date.now(), status: 'wfh' }], + }), + ]); + expect(service.total_members()).toBe(2); + expect(service.booked_count()).toBe(1); + }); + + describe('filter mutations', () => { + it('should merge partial filter updates', () => { + service.setFilters({ search: 'abc' }); + service.setFilters({ department: 'Sales' }); + expect(service.filters().search).toBe('abc'); + expect(service.filters().department).toBe('Sales'); + }); + + it('should reset filters back to defaults', () => { + service.setFilters({ search: 'abc', view: 'team', day_index: 2 }); + service.resetFilters(); + const filters = service.filters(); + expect(filters.search).toBe(''); + expect(filters.view).toBe('all'); + expect(filters.day_index).toBe(-1); + }); + + it('should shift a week back and reset the day index', () => { + const before = service.filters().date; + service.previousWeek(); + expect(service.filters().date).toBe(subWeeks(before, 1).valueOf()); + expect(service.filters().day_index).toBe(0); + }); + + it('should shift a week forward and reset the day index', () => { + const before = service.filters().date; + service.nextWeek(); + expect(service.filters().date).toBe(addWeeks(before, 1).valueOf()); + expect(service.filters().day_index).toBe(0); + }); + }); + + describe('selection mode', () => { + it('should enter and leave select mode, clearing selection on exit', () => { + const member = makeMember(); + service.toggleSelectMode(); + expect(service.select_mode()).toBe(true); + service.toggleMemberSelection(member); + expect(service.selected_count()).toBe(1); + service.toggleSelectMode(); + expect(service.select_mode()).toBe(false); + expect(service.selected_count()).toBe(0); + }); + + it('should toggle individual member selection on and off', () => { + const member = makeMember(); + service.toggleMemberSelection(member); + expect(service.isMemberSelected(member)).toBe(true); + service.toggleMemberSelection(member); + expect(service.isMemberSelected(member)).toBe(false); + }); + + it('should resolve selected members from selected ids', () => { + const a = makeMember({ + user: { id: 'a', name: 'A', email: 'a@x' } as any, + }); + const b = makeMember({ + user: { id: 'b', name: 'B', email: 'b@x' } as any, + }); + set_members([a, b]); + service.toggleMemberSelection(a); + expect(service.selected_members().map((m) => m.user.id)).toEqual([ + 'a', + ]); + }); + + it('should clear selection and exit select mode', () => { + const member = makeMember(); + service.toggleSelectMode(); + service.toggleMemberSelection(member); + service.clearSelection(); + expect(service.select_mode()).toBe(false); + expect(service.selected_count()).toBe(0); + }); + }); + + describe('favourites and team membership', () => { + const user = new StaffUser({ + email: 'fav@example.com', + name: 'Fav', + } as any); + + beforeEach(() => { + set_members([ + makeMember({ + user: { + id: 'fav@example.com', + name: 'Fav', + email: 'fav@example.com', + } as any, + }), + ]); + }); + + it('should add a favourite, persist it and flag the member', () => { + service.addFavorite(user); + expect(save_setting).toHaveBeenCalledWith( + 'favourite_team_members', + ['fav@example.com'], + ); + expect(service.isFavorite(user)).toBe(true); + expect( + (service as any)._team_members()[0].is_favorite, + ).toBe(true); + }); + + it('should not persist a duplicate favourite', () => { + settings_store['favourite_team_members'] = ['fav@example.com']; + service.addFavorite(user); + expect(save_setting).not.toHaveBeenCalled(); + }); + + it('should remove a favourite and clear the member flag', () => { + settings_store['favourite_team_members'] = ['fav@example.com']; + (service as any)._favorite_ids.set(new Set(['fav@example.com'])); + service.removeFavorite(user); + expect(save_setting).toHaveBeenCalledWith( + 'favourite_team_members', + [], + ); + expect(service.isFavorite(user)).toBe(false); + expect( + (service as any)._team_members()[0].is_favorite, + ).toBe(false); + }); + + it('should add and remove team members via settings', () => { + service.addTeamMember(user); + expect(save_setting).toHaveBeenCalledWith('team_members', [ + 'fav@example.com', + ]); + expect(service.isTeamMember(user)).toBe(true); + service.removeTeamMember(user); + expect(save_setting).toHaveBeenLastCalledWith('team_members', []); + expect(service.isTeamMember(user)).toBe(false); + }); + + it('should toggle favourites based on current state', () => { + const add_spy = vi.spyOn(service, 'addFavorite'); + const remove_spy = vi.spyOn(service, 'removeFavorite'); + service.toggleFavoriteByUser(user); + expect(add_spy).toHaveBeenCalled(); + (service as any)._favorite_ids.set(new Set(['fav@example.com'])); + service.toggleFavoriteByUser(user); + expect(remove_spy).toHaveBeenCalled(); + }); + + it('should toggle a member favourite using is_favorite flag', () => { + const add_spy = vi.spyOn(service, 'addFavorite'); + const remove_spy = vi.spyOn(service, 'removeFavorite'); + service.toggleFavorite(makeMember({ is_favorite: false })); + expect(add_spy).toHaveBeenCalled(); + service.toggleFavorite(makeMember({ is_favorite: true })); + expect(remove_spy).toHaveBeenCalled(); + }); + + it('should toggle team membership using is_my_team flag', () => { + const add_spy = vi.spyOn(service, 'addTeamMember'); + const remove_spy = vi.spyOn(service, 'removeTeamMember'); + service.toggleTeamMember(makeMember({ is_my_team: false })); + expect(add_spy).toHaveBeenCalled(); + service.toggleTeamMember(makeMember({ is_my_team: true })); + expect(remove_spy).toHaveBeenCalled(); + }); + }); + + describe('helper accessors', () => { + it('should return the location style with a fallback', () => { + expect(service.getLocationStyle('office').icon).toBe('business'); + expect(service.getLocationStyle('nonsense' as any).icon).toBe( + 'help_outline', + ); + }); + + it('should return the member status for the active day', () => { + service.setFilters({ day_index: 1 }); + const member = makeMember({ + statuses: [ + { date: 1, status: 'wfh' }, + { date: 2, status: 'office' }, + ], + }); + expect(service.getMemberStatus(member)?.status).toBe('office'); + }); + + it('should return only my-team members', () => { + set_members([ + makeMember({ + user: { id: 'a', name: 'A', email: 'a@x' } as any, + is_my_team: true, + }), + makeMember({ + user: { id: 'b', name: 'B', email: 'b@x' } as any, + is_my_team: false, + }), + ]); + expect(service.getTeamMembers().map((m) => m.user.id)).toEqual([ + 'a', + ]); + }); + }); + + describe('loading colleagues and building statuses', () => { + const week_start = startOfWeek(Date.now(), { weekStartsOn: 1 }); + const monday = week_start.valueOf(); + + // Let the constructor's initial (empty) load settle before each test + // so it cannot overwrite the data we load via refresh(). + beforeEach(async () => { + await wait(); + }); + + const setupColleagues = (users: any[]) => { + (showMetadata as any).mockResolvedValue({ + details: users.map((u) => ({ email: u.email })), + }); + const by_email = new Map(users.map((u) => [u.email, u])); + (showUser as any).mockImplementation((email: string) => + Promise.resolve(by_email.get(email) || { email }), + ); + }; + + it('should map work preferences to location statuses', async () => { + setupColleagues([ + { + email: 'home@example.com', + name: 'Home', + // Every day of week set to wfh so every column maps to wfh + work_preferences: [0, 1, 2, 3, 4, 5, 6].map((day) => ({ + day_of_week: day, + blocks: [{ location: 'wfh' }], + })), + }, + ]); + + await service.refresh(); + await wait(); + + const members = (service as any)._team_members(); + expect(members).toHaveLength(1); + expect( + members[0].statuses.every((s: any) => s.status === 'wfh'), + ).toBe(true); + }); + + it('should map every known work location code', async () => { + const codes: Array<[string, string]> = [ + ['wfo', 'office'], + ['aol', 'on_leave'], + ['sick', 'off_sick'], + ['ooo', 'unspecified'], + ['unknown', 'unspecified'], + ]; + for (const [code, expected] of codes) { + setupColleagues([ + { + email: `${code}@example.com`, + name: code, + work_preferences: [0, 1, 2, 3, 4, 5, 6].map((day) => ({ + day_of_week: day, + blocks: [{ location: code }], + })), + }, + ]); + await service.refresh(); + await wait(); + const member = (service as any)._team_members()[0]; + expect(member.statuses[0].status).toBe(expected); + } + }); + + it('should default to unspecified when no work preference exists', async () => { + setupColleagues([ + { email: 'none@example.com', name: 'None', work_preferences: [] }, + ]); + await service.refresh(); + await wait(); + const member = (service as any)._team_members()[0]; + expect( + member.statuses.every((s: any) => s.status === 'unspecified'), + ).toBe(true); + }); + + it('should prefer a date-specific override over the weekly preference', async () => { + const monday_key = format(new Date(monday), 'yyyy-MM-dd'); + setupColleagues([ + { + email: 'ovr@example.com', + name: 'Override', + work_preferences: [0, 1, 2, 3, 4, 5, 6].map((day) => ({ + day_of_week: day, + blocks: [{ location: 'wfh' }], + })), + work_overrides: { + [monday_key]: { blocks: [{ location: 'aol' }] }, + }, + }, + ]); + await service.refresh(); + await wait(); + const member = (service as any)._team_members()[0]; + expect(member.statuses[0].status).toBe('on_leave'); + expect(member.statuses[1].status).toBe('wfh'); + }); + + it('should override status to office and attach booking details when a desk is booked', async () => { + setupColleagues([ + { + email: 'booked@example.com', + name: 'Booked', + work_preferences: [0, 1, 2, 3, 4, 5, 6].map((day) => ({ + day_of_week: day, + blocks: [{ location: 'wfh' }], + })), + }, + ]); + vi.mocked(get).mockResolvedValue([ + { + date: monday + 9 * 3600 * 1000, + duration: 480, + zones: ['zone-bld', 'zone-lvl'], + asset_name: 'Desk 42', + asset_id: 'desk-42', + description: '', + }, + ] as any); + + await service.refresh(); + await wait(); + + const member = (service as any)._team_members()[0]; + expect(member.statuses[0].status).toBe('office'); + expect(member.statuses[0].booking).toMatchObject({ + building_name: 'Building One (HQ)', + desk_code: 'Desk 42', + desk_id: 'desk-42', + level_id: 'zone-lvl', + duration: 480, + }); + }); + + it('should flag favourites and team members from settings', async () => { + settings_store['favourite_team_members'] = ['fav@example.com']; + settings_store['team_members'] = ['team@example.com']; + setupColleagues([ + { email: 'fav@example.com', name: 'Fav', work_preferences: [] }, + { email: 'team@example.com', name: 'Team', work_preferences: [] }, + { email: 'plain@example.com', name: 'Plain', work_preferences: [] }, + ]); + + await service.refresh(); + await wait(); + + const members = (service as any)._team_members() as TeamMember[]; + const fav = members.find((m) => m.user.email === 'fav@example.com'); + const team = members.find((m) => m.user.email === 'team@example.com'); + const plain = members.find( + (m) => m.user.email === 'plain@example.com', + ); + expect(fav?.is_favorite).toBe(true); + expect(fav?.is_my_team).toBe(false); + expect(team?.is_my_team).toBe(true); + expect(plain?.is_favorite).toBe(false); + expect(plain?.is_my_team).toBe(false); + }); + + it('should clear the loading flag after a refresh', async () => { + await service.refresh(); + await wait(); + expect(service.loading()).toBe(false); + }); + + it('should request bookings for a week change and not for other filters', async () => { + setupColleagues([{ email: 'a@example.com', name: 'A' }]); + await service.refresh(); + await wait(); + vi.mocked(get).mockClear(); + + service.setFilters({ search: 'a', view: 'team', status: 'wfh' }); + TestBed.tick(); + await wait(); + expect(get).not.toHaveBeenCalled(); + + service.nextWeek(); + TestBed.tick(); + await wait(); + expect(get).toHaveBeenCalledTimes(1); + }); + + it('should drop a late response for an old week', async () => { + setupColleagues([{ email: 'a@example.com', name: 'A' }]); + await service.refresh(); + await wait(); + let resolveOldWeek: (list: unknown[]) => void = () => null; + vi.mocked(get) + .mockReturnValueOnce( + new Promise((resolve) => (resolveOldWeek = resolve)) as any, + ) + .mockResolvedValue([] as any); + + service.nextWeek(); + TestBed.tick(); + service.nextWeek(); + TestBed.tick(); + await wait(); + resolveOldWeek([]); + await wait(); + + const member = (service as any)._team_members()[0]; + expect(member.statuses[0].date).toBe(service.week_days()[0].date); + expect(service.loading()).toBe(false); + }); + }); +}); diff --git a/apps/workplace/tsconfig.spec.json b/apps/workplace/tsconfig.spec.json index 9e122a493e..a69d9d60ac 100644 --- a/apps/workplace/tsconfig.spec.json +++ b/apps/workplace/tsconfig.spec.json @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "isolatedModules": true }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "src/main.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/docs/settings/README.md b/docs/settings/README.md index 779ea4f391..40a02e38d7 100644 --- a/docs/settings/README.md +++ b/docs/settings/README.md @@ -54,6 +54,45 @@ Root settings such as `composer`, `mock` and `service_worker` are outside `app`. The loader caches zone data in the browser. Reload the app after a metadata change. If cached values remain, use `window.app.org.reloadMetadata()` in debug mode to clear the organisation cache and fetch it again. +## Theme colour overrides + +Apps that use the shared settings service read two maps of CSS variables. Each key is a variable name from `shared/styles/application.css` without the `--` prefix. Each value is a CSS colour. + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `css_variables` | object | – | Colour overrides for the light theme. In the dark theme, they apply only to variables that the dark theme does not set. | +| `css_variables_dark` | object | – | Colour overrides for the dark theme. Repeat a brand colour here to use it in the two themes. | + +```json +{ + "css_variables": { + "brand-100": "#00afd0", + "secondary": "#006a9d" + }, + "css_variables_dark": { + "brand-100": "#00afd0", + "secondary": "#006a9d", + "base-100": "oklch(23% 0.034 264.665)" + } +} +``` + +## Domain configuration + +The loading screen shows before the zone settings load. Its colours come from the `config` object of the domain (authority) in Backoffice, not from zone metadata. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `loading_gradient` | string[] | blue gradient | Two or more CSS colours for the background of the loading screen, from left to right. Hex, named, `rgb()`, `hsl()` and `oklch()` colours are valid. The app ignores the list if a value is not a colour. | + +```json +{ + "loading_gradient": ["#f15b55", "#f68c50"] +} +``` + +The browser stores the last gradient, so a reload shows it immediately. After a change to the domain config, the first load shows the old colours for about one second. + ## Local settings overrides Apps with a settings debug panel use **Ctrl+Alt+Shift+S** to open it. A saved override applies only in that browser. It does not update Backoffice. Overrides persist in local storage under `PLACEOS.setting_overrides` and take priority over zone settings. @@ -62,6 +101,7 @@ Overrides use exact keys. An override for `app.events` does not override a looku ## Source references -- [Settings lookup and debug overrides](../../libs/common/src/lib/settings.service.ts) +- [Settings lookup, colour overrides and debug overrides](../../libs/common/src/lib/settings.service.ts) +- [Loading screen gradient](../../libs/components/src/lib/global-loading.component.ts) - [Metadata loading and priority](../../libs/common/src/lib/org/organisation.service.ts) - [Settings debug panel shortcut](../../libs/components/src/lib/settings-debug-panel-launcher.component.ts) diff --git a/docs/settings/concierge.md b/docs/settings/concierge.md index 09406b293d..0baa5fbc5f 100644 --- a/docs/settings/concierge.md +++ b/docs/settings/concierge.md @@ -11,7 +11,7 @@ Set settings in Backoffice zone metadata under `concierge_app` for the standard | `name` | string | `"Concierge"` | Name of the application. | | `title` | string | `"PlaceOS"` | Title shown in the browser tab. | | `description` | string | `"Concierge UI"` | Description of the application. | -| `short_name` | string | `"PlaceOS"` | Short name for the application. | +| `short_name` | string | `"Concierge"` | Short name for the application. | | `logo_light` | string / object | `"assets/logo-light.svg"` | Logo shown when using the light theme. Also used in generated PDF reports. Either a URL string or an object with a `src` URL. | | `logo_dark` | string / object | `"assets/logo-dark.svg"` | Logo shown when using the dark theme. Also used in generated PDF reports. Either a URL string or an object with a `src` URL. | | `default_route` | string | `"/book/rooms"` | Route used for the sidebar bookings link when no matching feature is enabled, e.g. `/book/rooms`. | @@ -187,6 +187,7 @@ Each custom action requires an `id` (passed to the action handler when selected) | `parking.assign_space_on_approve` | boolean | `false` | Automatically assign a parking space when approving a parking booking. | | `parking.max_assigned_count` | number | `0` | Maximum number of parking spaces that can be assigned to a single user. `0` for unlimited. | | `parking.custom_booking_columns` | array | `[]` | Additional columns to display in the parking bookings list, sourced from booking extension data. | +| `parking.show_user_groups` | string[] | `[]` | User group names to show in the parking bookings list. The column shows the groups of each booking that are in this list. It is hidden when the list is empty. | | `parking.use_building_timezone` | boolean | – | Handle parking dates and times in the active building's timezone. Falls back to `bookings.use_building_timezone`. | | `parking.bookable_hours` | object | – | Time window during which parking bookings are allowed, as hours of the day (0-24). | | `parking.min_duration` | number | – | Minimum parking booking duration in minutes. Falls back to `bookings.min_duration`. | @@ -268,6 +269,32 @@ Each custom report requires an `id` (unique, URL-safe), a `name` (shown in the r |---|---|---|---| | `group_events_calendar` | string | – | ID of the calendar used for storing group events. | +## Analytics + +| Setting | Type | Default | Description | +|---|---|---|---| +| `analytics.enabled` | boolean | `true` | Enable Google Analytics. | +| `analytics.tracking_id` | string | `""` | Google Analytics tracking ID. Analytics is only initialised when this is set. | + +```json +{ + "analytics": { + "enabled": true, + "tracking_id": "G-XXXXXXXXXX" + } +} +``` + +For GA4 tracking IDs (`G-...`), the app sends page views after successful router +navigation. The page path has no query string or fragment. In the GA4 web data stream, open **Enhanced measurement > Page views > +Advanced settings** and turn off **Page changes based on browser history events**. +The app disables the automatic page view on tag load. The GA4 property setting +must also be off to prevent duplicate navigation events. See +[Google's manual page-view setup](https://developers.google.com/analytics/devguides/collection/ga4/views#manual_pageviews). + +Set `analytics.enabled` to `false` and reload the app to stop analytics +initialisation and event tracking. + ## Integration & Advanced Settings for linking Concierge to the other PlaceOS applications and metadata sources. Most deployments can leave these at their defaults. diff --git a/docs/settings/workplace.md b/docs/settings/workplace.md index f32ebfaeb3..0c1141ce42 100644 --- a/docs/settings/workplace.md +++ b/docs/settings/workplace.md @@ -25,10 +25,12 @@ These options live at the root of the settings file (alongside `app`, not inside | `name` | string | `"Workplace"` | Name of the application, used in the browser page title. | | `title` | string | `"Workplace Application"` | Display title for the application. | | `description` | string | `"PlaceOS Workplace UI written with Angular Framework"` | Description of the application. | -| `short_name` | string | `"WorkMate"` | Short name for the application. | +| `short_name` | string | `"Workplace"` | Short name for the application. | | `logo_light` | string \| object | `"assets/logo-light.svg"` | Logo shown in the top bar when using the light theme. Either an image URL or an icon object with a `src` property. | | `logo_dark` | string \| object | `"assets/logo-dark.svg"` | Logo shown in the top bar when using the dark theme. Either an image URL or an icon object with a `src` property. | | `environment_bar` | string | – | CSS colour for thin environment identifier bars shown at the top and bottom of the app (useful for marking test/UAT environments). Leave unset to hide the bars. | +| `css_variables` | object | – | Colour overrides for the light theme. See [theme colour overrides](README.md#theme-colour-overrides). | +| `css_variables_dark` | object | – | Colour overrides for the dark theme. See [theme colour overrides](README.md#theme-colour-overrides). | | `sentry_dsn` | string | – | Connection string for Sentry error reporting. | | `show_changelog` | boolean | `true` | Whether the version number in the user controls links to the changelog modal. | | `allow_dark_mode` | boolean | `true` | Allow users to switch the application to the dark theme. | @@ -40,12 +42,11 @@ These options live at the root of the settings file (alongside `app`, not inside | `week_start` | number | – | First day of the week for schedule and calendar views. `0` is Sunday through `6` for Saturday. | | `idle_timeout` | number | `5` | Minutes of inactivity on the meeting form before the user is prompted to discard their changes. | | `global_search` | boolean | `true` | Show the global search bar in the top bar. | -| `hide_landing_sidebar` | boolean | – | Hide the colleagues and favourites sidebar on the landing page. | -| `hide_colleagues` | boolean | – | Hide the colleagues tab on the landing page. Show favourites in its place. | -| `hide_landing_spaces` | boolean | – | Hide the availability summary by level on the landing page. | -| `hide_landing_rooms` | boolean | – | Hide the available meeting rooms list on the landing page. | -| `show_quick_links` | boolean | – | Show quick links on the landing page. Hidden when unset. | -| `show_quick_book` | boolean | – | Show quick booking actions on the landing page. Hidden when unset. | +| `hide_colleagues` | boolean | – | Hide the colleagues card on the landing page. The favourites card takes the full row. | +| `hide_landing_spaces` | boolean | – | Hide the Desks and Lockers tabs of the Available Now card on the landing page. | +| `hide_landing_rooms` | boolean | – | Hide the Rooms tab of the Available Now card on the landing page. The card is hidden when no tab is left. | +| `show_quick_links` | boolean | – | Show the booking links in the Quick Actions card on the landing page, one for each enabled feature. Shown when not set; set to `false` to hide them. | +| `show_quick_book` | boolean | – | Show the Auto-assign Desk action in the Quick Actions card on the landing page. Needs the `desks` feature. Shown when not set; set to `false` to hide it. | | `features` | string[] | see below | List of feature flags controlling which sections of the app are available. | | `locales` | object[] | 10 languages | Languages available in the language selector. | | `menu_embeds` | object[] | – | Additional menu items that open third-party URLs embedded inside the app. | @@ -203,7 +204,7 @@ These settings override the matching `bookings.*` settings for visitor invites. |---------|------|---------|-------------| | `visitors.reason_required` | boolean | `false` | Require a reason for each visitor invite. Starts new invites with an empty reason and rejects blank or whitespace-only reasons. | | `visitors.allow_editing` | boolean | `false` | Allow visitor booking edits from the shared booking details modal, subject to the booking status and user permissions. | -| `visitors.allow_all_day` | boolean | – | Make the all-day option available for visitor invites. Falls back to `bookings.allow_all_day`. | +| `visitors.allow_all_day` | boolean | `true` | Make the all-day option available for visitor invites. Falls back to `bookings.allow_all_day` when not set. | | `visitors.can_book_for_others` | boolean | – | Allow users to invite visitors on behalf of another internal host. Falls back to `bookings.can_book_for_others`. | | `visitors.can_book_for_anyone` | boolean | – | Allow users to invite visitors on behalf of any host, including an external host. Falls back to `bookings.can_book_for_anyone`. | @@ -222,6 +223,7 @@ These settings override the matching `bookings.*` settings for visitor invites. | `events.has_assets` | boolean | `true` | Make asset requests available in the meeting booking flow. | | `events.allow_all_day` | boolean | `true` | Make the "all day" option available for meetings. | | `events.allow_multiday` | boolean | – | Allow meetings to span multiple days. | +| `events.allowed_future_days` | number | `180` | Number of days into the future that a meeting can be booked. | | `events.allow_externals` | boolean | `true` | Allow external (non-directory) attendees to be added to meetings. | | `events.allow_recurrence` | boolean | `false` | Allow recurring meetings to be created. | | `events.allow_daily_allday_recurrence` | boolean | `false` | Allow daily recurrence for all-day meetings. | @@ -262,6 +264,7 @@ These settings override the matching `bookings.*` settings for visitor invites. | `desks.has_assets` | boolean | – | Make asset requests available in the desk booking flow. Overrides `bookings.has_assets`. | | `desks.hide_map` | boolean | `false` | Hide the map when selecting desks. | | `desks.default_select_as_map` | boolean | `false` | Open the desk selection modal on the map view instead of the list view. | +| `desks.show_users` | boolean | `true` | Show desk users in desk tooltips. On the Explore map, this controls the staff name from the building system. On the desk booking map, this controls the names of the people who booked the desk in the selected time window. | | `desks.needs_reason` | boolean | – | Require users to enter a reason for their desk booking. | | `desks.hide_reason` | boolean | – | Hide the ability to enter a reason for a desk booking. | | `desks.show_calendar_links` | boolean | `true` | Show "add to calendar" links on the desk booking success view. | @@ -298,7 +301,10 @@ These settings override the matching `bookings.*` settings for visitor invites. | `parking.hide_selected_space` | boolean | `false` | Hide selected parking space names in booking cards and booking detail modals. | | `parking.hide_availability_counter` | boolean | `false` | Hide the parking availability counter shown next to the selected location or level. | | `parking.require_plate_number` | boolean | `false` | Make the plate number/registration field mandatory in the parking request flow. | +| `parking.show_special_needs` | boolean | `false` | Show the special needs reason and document fields to each user who selects the `special` request type. When `false`, only parking users with the special needs flag see them. | | `parking.show_status_details` | boolean | – | Show availability status details (status text, assigned users, plate numbers) in the explore map parking tooltip. | +| `parking.show_waitlist` | boolean | – | Show unallocated parking bookings in the active waitlist week as waitlisted. Shown when not set; set to `false` to hide the status. | +| `parking.waitlist_week_start` | object | Friday at 18:00 | Day and time at which the active parking waitlist week starts. Needs a `day` (`0` is Sunday), an `hour` and a `minute`. | Request types also accept `approver_groups` to restrict the approver options, `show_notes` to show the notes field, and `requires_manual_approval` to mark the request for manual approval. The last two fields default to `false`. @@ -468,3 +474,37 @@ Per-resource-type overrides can be set with `_time_before` and `_tim | `chat.enabled` | boolean | `false` | Show the global chat widget in the app. | | `can_deliver` | boolean | `true` | Make delivery available for catering orders. | | `group_events_calendar` | string | – | ID (email address) of the calendar used to list group events. | + +For GA4 tracking IDs (`G-...`), the app sends page views after successful router +navigation. In the GA4 web data stream, open **Enhanced measurement > Page views > +Advanced settings** and turn off **Page changes based on browser history events**. +The app disables the automatic page view on tag load. The GA4 property setting +must also be off to prevent duplicate navigation events. See +[Google's manual page-view setup](https://developers.google.com/analytics/devguides/collection/ga4/views#manual_pageviews). + +Set `analytics.enabled` to `false` and reload the app to stop analytics +initialisation and event tracking. + +## Virtual Concierge (`virtual_concierge.*`) + +A button for the virtual concierge shows on the schedule, team schedule, explore, deals and directory pages when a URL is set. + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `virtual_concierge.url` | string | – | URL of the virtual concierge page to embed. The button is hidden when this is empty. | +| `virtual_concierge_url` | string | – | Legacy key for the URL. Used when `virtual_concierge.url` is empty. | +| `virtual_concierge.icon` | string | – | URL of the image for the button. A default icon shows when this is empty. | +| `virtual_concierge.display` | string | `"fullscreen"` | How the embed opens: `fullscreen` or `sidebar`. | +| `virtual_concierge.side` | string | `"left"` | Side of the screen for the sidebar: `left` or `right`. | +| `virtual_concierge.prompts` | string[] | three example prompts | Suggested prompts for the virtual concierge UI. | + +```json +{ + "virtual_concierge": { + "url": "https://concierge.example.com", + "icon": "https://concierge.example.com/icon.png", + "display": "sidebar", + "side": "right" + } +} +``` diff --git a/libs/assets/tsconfig.spec.json b/libs/assets/tsconfig.spec.json index 94be5ce7ec..957611b10c 100644 --- a/libs/assets/tsconfig.spec.json +++ b/libs/assets/tsconfig.spec.json @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "isolatedModules": true }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "src/index.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/libs/bookings/src/lib/booking-card.component.ts b/libs/bookings/src/lib/booking-card.component.ts index 24410bfe99..18e48eb34d 100644 --- a/libs/bookings/src/lib/booking-card.component.ts +++ b/libs/bookings/src/lib/booking-card.component.ts @@ -6,6 +6,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, RouterModule } from '@angular/router'; import { Booking, + BOOKING_TYPE_COLORS, currentUser, formatDuration, formatRecurrence, @@ -22,6 +23,7 @@ import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { GroupEventDetailsModalComponent } from 'libs/events/src/lib/group-event-details-modal.component'; import { BookingDetailsModalComponent } from './booking-details-modal.component'; import { + bookingLocationString, formatEmailName, parkingRequestStatus, visitorDisplayNameFor, @@ -40,7 +42,7 @@ const TYPE_ICONS = new Map([ selector: 'booking-card', template: ` @if (booking()) { -

+

@if (show_day()) { {{ day() }},  } @@ -64,6 +66,18 @@ const TYPE_ICONS = new Map([ class="border-base-300 bg-base-100 relative w-full rounded-xl border py-4 shadow-sm" [class.opacity-60]="is_cancelled()" > +
+ + {{ typeIcon }} + +

{{ booking()?.title }}

@@ -72,6 +86,7 @@ const TYPE_ICONS = new Map([ } {{ period() }} + @if (!for_current_user() && booked_for_label()) {
([ class="divide-base-200-500 flex flex-col flex-wrap space-y-2 py-2 sm:flex-row sm:space-y-0 sm:divide-x" >
- {{ type_icon().icon }} + {{ type_icon().icon }}
{{ resource_label() }}
@@ -133,7 +144,7 @@ const TYPE_ICONS = new Map([ } @for (badge of warning_badges(); track badge) {
{{ badge | translate }}
@@ -253,6 +264,52 @@ export class BookingCardComponent { public readonly type = computed(() => this.booking()?.type); + private _open_timer?: ReturnType; + + constructor() { + const destroy_ref = inject(DestroyRef); + destroy_ref.onDestroy(() => clearTimeout(this._open_timer)); + this._route.queryParamMap + .pipe(takeUntilDestroyed()) + .subscribe((params) => + params.has('booking') && + this.booking()?.id === params.get('event') + ? this.viewDetails() + : '', + ); + } + + public get typeIcon() { + const type = this.booking()?.booking_type; + const iconMap = { + event: 'meeting_room', + desk: 'desk', + parking: 'drive_eta', + visitor: 'people', + locker: 'lock', + 'group-event': 'event_available', + }; + return iconMap[type] || 'book'; + } + + public get typeLabel() { + const type = this.booking()?.booking_type; + const labelMap = { + event: 'RESOURCE.ROOM', + desk: 'RESOURCE.DESK', + parking: 'RESOURCE.PARKING', + visitor: 'RESOURCE.VISITOR', + locker: 'RESOURCE.LOCKER', + 'group-event': 'RESOURCE.EVENT', + }; + return labelMap[type] || 'RESOURCE.BOOKING'; + } + + public get typeColors() { + const type = this.booking()?.booking_type; + return BOOKING_TYPE_COLORS[type] || ['#E5E7EB', '#1F2937']; + } + /** Icon and tooltip key for the booking type. Other types show a book * icon with no tooltip. */ public readonly type_icon = computed( @@ -285,10 +342,9 @@ export class BookingCardComponent { return `${is_today ? i18n('COMMON.TODAY') : format(date, 'EEEE')}`; }); - public readonly location = computed(() => { - const level = this._org.levelWithID(this.booking()?.zones || []); - return `${level?.display_name || level?.name || ''}`; - }); + public readonly location = computed(() => + bookingLocationString(this.booking(), this._org), + ); public readonly period = computed(() => { const booking = this.booking(); @@ -318,29 +374,24 @@ export class BookingCardComponent { return i18n('RESOURCE.PARKING'); } if (booking.booking_type !== 'visitor') { - return ( - this.raw_description() || booking.asset_name || booking.asset_id - ); + const label = + this.raw_description() || + booking.asset_name || + booking.asset_id || + ''; + // Unallocated parking has no space yet; hide the raw `unallocated-*` + // asset id that the label can fall back to. + if (label.startsWith('unallocated')) { + return booking.booking_type === 'parking' + ? i18n('RESOURCE.PARKING') + : ''; + } + return label; } return visitorDisplayNameFor(booking); }); - private _open_timer: ReturnType; - - constructor() { - const destroy_ref = inject(DestroyRef); - destroy_ref.onDestroy(() => clearTimeout(this._open_timer)); - this._route.queryParamMap - .pipe(takeUntilDestroyed()) - .subscribe((params) => - params.has('booking') && - this.booking()?.id === params.get('event') - ? this.viewDetails() - : '', - ); - } - - public removeHtmlTags(html: string) { + public removeHtmlTags(html = '') { const doc = new DOMParser().parseFromString(html, 'text/html'); return doc.body.textContent || ''; } diff --git a/libs/bookings/src/lib/booking-details-modal.component.ts b/libs/bookings/src/lib/booking-details-modal.component.ts index 0b380572e6..6e2df81806 100644 --- a/libs/bookings/src/lib/booking-details-modal.component.ts +++ b/libs/bookings/src/lib/booking-details-modal.component.ts @@ -40,6 +40,23 @@ import { import { setBookingCheckedIn } from './bookings.fn'; import { DeskSettingsModalComponent } from './desk-settings-modal.component'; +export function canEditBooking(booking: Booking) { + const is_visitor = booking.booking_type === 'visitor'; + const visitor_edit_allowed = + is_visitor && settingSignal('visitors.allow_editing', false)(); + const is_parking = booking.booking_type === 'parking'; + const features: string[] = settingSignal('features', [])(); + const parking_allocated_edit_blocked = + is_parking && !!booking.asset_id && !features.includes('parking'); + return ( + booking.status !== 'cancelled' && + !booking.is_done && + !booking.checked_in && + (!is_visitor || visitor_edit_allowed) && + !parking_allocated_edit_blocked + ); +} + @Component({ selector: 'booking-details-modal', template: ` @@ -585,24 +602,7 @@ export class BookingDetailsModalComponent { (!this.is_cancelled() || this.can_manage_group()), ); - public readonly can_edit = computed(() => { - const is_visitor = this.booking().booking_type === 'visitor'; - const visitor_edit_allowed = - is_visitor && settingSignal('visitors.allow_editing', false)(); - const is_parking = this.booking().booking_type === 'parking'; - const features: string[] = settingSignal('features', [])(); - const parking_allocated_edit_blocked = - is_parking && - !!this.booking().asset_id && - !features.includes('parking'); - return ( - !this.is_cancelled() && - !this.booking().is_done && - !this.booking().checked_in && - (!is_visitor || visitor_edit_allowed) && - !parking_allocated_edit_blocked - ); - }); + public readonly can_edit = computed(() => canEditBooking(this.booking())); public readonly can_cancel = computed( () => diff --git a/libs/bookings/src/lib/booking-form.service.ts b/libs/bookings/src/lib/booking-form.service.ts index 5fd163c312..bd08b994ee 100644 --- a/libs/bookings/src/lib/booking-form.service.ts +++ b/libs/bookings/src/lib/booking-form.service.ts @@ -10,6 +10,7 @@ import { signal, type Signal, untracked, + type WritableSignal, } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Event, NavigationEnd, Router } from '@angular/router'; @@ -35,6 +36,8 @@ import { notifyWarn, OrganisationService, rulesForResource, + SETTING_KEYS, + settingSignal, SettingsService, unique, User, @@ -91,6 +94,7 @@ import { groupAvailability, nearbyResources, pickAutoAllocatedResource, + preferredAllocationPool, reserveResource, resourceReserved, } from './booking-resource.utilities'; @@ -117,6 +121,7 @@ const STORAGE_KEYS = { booking_form_options: 'PLACEOS.booking_form_options', booking_form_filters: 'PLACEOS.booking_form_filters', last_booked_booking: 'PLACEOS.last_booked_booking', + last_booked_count: 'PLACEOS.last_booked_count', last_group_booking_ids: 'PLACEOS.last_group_booking_ids', last_group_booking_errors: 'PLACEOS.last_group_booking_errors', } as const; @@ -137,6 +142,29 @@ function bookingOptionsMatch(a: BookingFlowOptions, b: BookingFlowOptions) { return keys.every((key) => a[key] === b[key]); } +const AVAILABILITY_SELECTION_FIELDS = new Set([ + 'resources', + 'booking_asset', + 'asset_id', + 'asset_name', + 'map_id', + 'name', + 'description', + 'zones', +]); + +function availabilityFormMatch( + a: Record, + b: Record, +) { + if (!a || !b) return a === b; + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + return [...keys].every( + (key) => + AVAILABILITY_SELECTION_FIELDS.has(key) || Object.is(a[key], b[key]), + ); +} + function assetDateValue(date: unknown) { const date_value = date instanceof Date ? date.valueOf() : Number(date); return Number.isFinite(date_value) ? date_value : null; @@ -213,6 +241,19 @@ export class BookingFormService extends AsyncHandler { private _booking = signal(null); private _resource_use: Record = {}; private _loading = signal(''); + private _favourites: Record> = { + ' ': settingSignal('favorites', [], true), + room: settingSignal(SETTING_KEYS.FAVORITE_ROOMS, [], true), + group: signal([]), + desk: settingSignal(SETTING_KEYS.FAVORITE_DESKS, [], true), + locker: settingSignal(SETTING_KEYS.FAVORITE_LOCKERS, [], true), + parking: settingSignal(SETTING_KEYS.FAVORITE_PARKING_SPACES, [], true), + staff: settingSignal('favorites', [], true), + visitor: settingSignal('favorites', [], true), + 'group-event': settingSignal('favorites', [], true), + 'asset-request': settingSignal('favorites', [], true), + 'catering-order': settingSignal('favorites', [], true), + }; private _network_requested = false; private _network_consumed = signal(false); private _booked_resource_requests = new Map>(); @@ -229,6 +270,15 @@ export class BookingFormService extends AsyncHandler { sessionStorage.getItem(STORAGE_KEYS.last_booked_booking) || '{}', ), ); + public get last_count(): number { + return parseInt( + sessionStorage.getItem(STORAGE_KEYS.last_booked_count) || '1', + 10, + ); + } + public set last_count(value: number) { + sessionStorage.setItem(STORAGE_KEYS.last_booked_count, String(value)); + } /** Signal emitting the current loading message, empty when idle */ public readonly loading = this._loading.asReadonly(); /** Signal for the active booking flow options */ @@ -394,7 +444,7 @@ export class BookingFormService extends AsyncHandler { private readonly _form_value = signal>(null); private readonly _form_value_debounced = debounced(this._form_value, 500, { injector: this._injector, - equal: Object.is, + equal: availabilityFormMatch, }); /** Params driving the resource list, debounced to coalesce rapid changes */ @@ -565,7 +615,6 @@ export class BookingFormService extends AsyncHandler { /** Resolve with the available resources for the current selection */ public async listAvailableResources(): Promise { this._startNetwork(); - this._form_value.set(this.model()); await firstValueWhere( computed( () => @@ -574,16 +623,19 @@ export class BookingFormService extends AsyncHandler { this._booking_rules_params() && // Rules stay idle when there are no buildings to load. (!this._booking_rules_params() || - resourceSettled(this._booking_rules_resource)) && - // Form effects can replace the initial snapshot before the - // debounce completes. Wait for the current model. - this._form_value_debounced.value() === this.model() && - resourceSettled(this._available_resource), + resourceSettled(this._booking_rules_resource)), ), (ready) => ready, this._injector, ); - return this.available_resources(); + // Compute from the current model rather than waiting on the debounced + // availability resource, which ignores selection-only form changes. + return this._computeAvailableResources( + this._options(), + this.resources(), + this.booking_rules(), + this.model(), + ); } /** @@ -698,12 +750,14 @@ export class BookingFormService extends AsyncHandler { if (all_day) { ({ date, duration } = this._allDayTimeRange(date)); } - const zones = - options.zone_id || + const favourites = this._favourites[options.type]?.() || []; + const default_zone = (this._settings.get('app.use_region') ? this._org.region?.id - : this._org.building?.id) || - this._org.organisation.id; + : this._org.building?.id) || this._org.organisation.id; + const zones = options.zones?.length + ? options.zones.join(',') + : options.zone_id || default_zone; let booked_ids: string[] = []; if (!isMock()) { // Always exclude resources booked in the first-instance window. @@ -751,8 +805,20 @@ export class BookingFormService extends AsyncHandler { restrictions[this._org.building?.id] || [], ).hidden; + // Check zone filtering + const zone_filter = options.zones?.length + ? options.zones.some( + (zone_id) => + zone_id === asset.zone?.id || + zone_id === asset.zone?.parent_id, + ) + : options.zone_id + ? options.zone_id === asset.zone?.id || + options.zone_id === asset.zone?.parent_id + : true; return ( !is_restricted && + (!options.show_fav || favourites.includes(asset.id)) && (!asset.groups?.length || asset.groups.some((grp) => currentUser().groups.includes(grp), @@ -762,9 +828,7 @@ export class BookingFormService extends AsyncHandler { options.features?.every((_) => asset.features.includes(_), )) && - (!options.zone_id || - options.zone_id === asset.zone?.id || - options.zone_id === asset.zone?.parent_id) && + zone_filter && !booked_ids.includes(asset.id) ); }); @@ -856,6 +920,9 @@ export class BookingFormService extends AsyncHandler { ...booking.extension_data, attachments: bookingAttachments(booking), ...booking, + _in_progress: + booking.state === 'started' || + booking.state === 'in_progress', // `Booking` has no `user` object, only the flat `user_*` // fields, so the host has to be rebuilt from those. Without // it the form keeps the signed-in user and editing a @@ -922,10 +989,14 @@ export class BookingFormService extends AsyncHandler { this.subscription( 'router.bookings', this._router.events.subscribe((booking: Event) => { + const url = + booking instanceof NavigationEnd + ? booking.urlAfterRedirects || booking.url + : ''; if ( booking instanceof NavigationEnd && - !booking.url.includes('book') && - !BOOKING_TYPES.find((_) => booking.url.includes(_)) + !url.includes('book') && + !BOOKING_TYPES.find((_) => url.includes(_)) ) { this.clearForm(); } @@ -957,6 +1028,25 @@ export class BookingFormService extends AsyncHandler { }); } + /** + * Start and end times (ms) of the booking window for a form value. + * All-day values use the configured all-day period. + */ + public bookingWindow(value: { + date?: number; + duration?: number; + all_day?: boolean; + }) { + if (value.all_day) { + const { date, date_end } = this._allDayTimeRange(value.date); + return { start: date, end: date_end }; + } + return { + start: value.date, + end: addMinutes(value.date, value.duration).valueOf(), + }; + } + private _allDayTimeRange(date: number) { const period = this.setting('all_day_period'); return getAllDayTimeRange( @@ -1167,6 +1257,8 @@ export class BookingFormService extends AsyncHandler { public clearOldState() { sessionStorage.removeItem(STORAGE_KEYS.last_booked_booking); + sessionStorage.removeItem(STORAGE_KEYS.last_booked_count); + this._loading.set(''); this.last_success = new Booking(); } @@ -1227,6 +1319,7 @@ export class BookingFormService extends AsyncHandler { public async postForm(ignore_check = false, reset_form = true) { if (!this.form) throw 'No form for booking'; this._prepareFormForPost(); + localStorage.removeItem(STORAGE_KEYS.last_group_booking_ids); const value = this.model(); const effective_timezone = this.timezone || value.timezone; const booking = this._booking() || new Booking(); @@ -1273,6 +1366,13 @@ export class BookingFormService extends AsyncHandler { value.duration = all_day_period.duration; value.date_end = all_day_period.date_end; } + // date/duration/date_end are the source of truth for the booking + // window. The form model can carry stale booking_start/booking_end + // (spread in from a Booking when the form was seeded), and the Booking + // constructor prefers an explicit booking_end. Drop them so the saved + // booking's window is recomputed from the current date/duration. + delete (value as any).booking_start; + delete (value as any).booking_end; const q = bookingSaveQuery(value, booking); delete value.event_id; const zones = unique([ @@ -1369,14 +1469,18 @@ export class BookingFormService extends AsyncHandler { timezone: string, ignore_check: boolean, ) { + const type: BookingType = request.booking_type || this._options().type; if (ignore_check) { await this._checkAssignedResourceRestriction( request.user_email, - this._options().type, + type, ); return; } - await this._checkResourceAvailable(request, this._options().type); + // Visitor bookings hold the visitor email, not a bookable resource. + if (type !== 'visitor') { + await this._checkResourceAvailable(request, type); + } await this._checkResourceRules( request.resources, period.date, @@ -1385,7 +1489,7 @@ export class BookingFormService extends AsyncHandler { ); await this._checkRecurringClashes( { ...request, ...period, timezone }, - this._options().type, + type, ); } @@ -1417,13 +1521,13 @@ export class BookingFormService extends AsyncHandler { this._org.levelWithID(resources[0]?.zone_id) || resources[0]?.zone; return zone && zone instanceof Object ? unique([ - this._org.organisation.id, + this._org.organisation?.id, this._org.region?.id, zone.parent_id, zone.id, ]) : [ - this._org.organisation.id, + this._org.organisation?.id, this._org.region?.id, this._org.building?.id, ]; @@ -1535,14 +1639,18 @@ export class BookingFormService extends AsyncHandler { /** * Auto-allocate a desk from the active building. - * Picks the level with the most available desks, then selects one at random. + * Prefers desks with tags and homebase matching the user's groups, then + * picks the level with the most available desks and selects one at random. */ public async autoAllocateDesk(): Promise { const available = await this.listAvailableResources(); if (!available?.length) { throw i18n('BOOKINGS.DESK_AVAILABLE_ERROR'); } - const selected = pickAutoAllocatedResource(available); + // Prefer desks whose tags or homebase match the current user's groups + const selected = pickAutoAllocatedResource( + preferredAllocationPool(available, currentUser()?.groups || []), + ); this._patch({ resources: [selected], asset_id: selected.id, @@ -1641,6 +1749,15 @@ export class BookingFormService extends AsyncHandler { const asset = resources[i]; const assets = user.email == currentUser().email ? form.assets : []; + const zones = unique( + [ + this._org.organisation?.id, + this._org.region?.id, + asset?.zone?.parent_id, + asset?.zone?.id, + ...form.zones, + ].filter((_) => _), + ); this._patch({ ...form, assets, @@ -1650,6 +1767,7 @@ export class BookingFormService extends AsyncHandler { user_id: user.id, ...this._resourceFormData(asset), group: group_name, + zones, }); const bkn = await this.postForm(true, false).catch((error) => { const message = this._error_message(error); @@ -1742,6 +1860,10 @@ export class BookingFormService extends AsyncHandler { parent_id, group_name, fallback_zones: this._booking()?.zones, + org_zones: [ + this._org.organisation?.id, + this._org.region?.id, + ], }), ); const bkn = await this.postForm(true, false).catch((error) => { @@ -1878,6 +2000,10 @@ export class BookingFormService extends AsyncHandler { group_name, existing_zones: existing?.zones, fallback_zones: this._booking()?.zones, + org_zones: [ + this._org.organisation?.id, + this._org.region?.id, + ], }), ); } else { diff --git a/libs/bookings/src/lib/booking-form.types.ts b/libs/bookings/src/lib/booking-form.types.ts index 3fe36bbc6f..c825cf03d3 100644 --- a/libs/bookings/src/lib/booking-form.types.ts +++ b/libs/bookings/src/lib/booking-form.types.ts @@ -8,6 +8,8 @@ export interface BookingFlowOptions { type: BookingType; /** Zone to check available */ zone_id?: string; + /** List of zones to check available */ + zones?: string[]; /** List of features that the asset should associate */ features?: string[]; /** Whether booking is for a group */ @@ -39,6 +41,8 @@ export interface BookingAsset { groups?: string[]; assigned_to?: string; features: string[]; + tags?: string[]; + homebase?: string; } export interface GroupBookingFailure { diff --git a/libs/bookings/src/lib/booking-group.utilities.ts b/libs/bookings/src/lib/booking-group.utilities.ts index 8e5210102b..847ba7bda2 100644 --- a/libs/bookings/src/lib/booking-group.utilities.ts +++ b/libs/bookings/src/lib/booking-group.utilities.ts @@ -24,6 +24,8 @@ export function visitorMemberPatch( group_name: string; existing_zones?: string[]; fallback_zones?: string[]; + /** Organisation and region zones every visitor booking includes */ + org_zones?: string[]; }, ) { const member_name = member.name || member.email; @@ -39,11 +41,16 @@ export function visitorMemberPatch( !!member.extension_data?.international, company: (member as any).company || member.organisation, phone: member.phone, - zones: base_form.zones?.length - ? [...base_form.zones] - : opts.existing_zones?.length - ? [...opts.existing_zones] - : [...(opts.fallback_zones || [])], + zones: unique( + [ + ...(opts.org_zones || []), + ...(base_form.zones?.length + ? base_form.zones + : opts.existing_zones?.length + ? opts.existing_zones + : opts.fallback_zones || []), + ].filter((_) => _), + ), assets: [], attendees: [ new User({ diff --git a/libs/bookings/src/lib/booking-resource.loaders.ts b/libs/bookings/src/lib/booking-resource.loaders.ts index 03372156fb..7e2d0c8097 100644 --- a/libs/bookings/src/lib/booking-resource.loaders.ts +++ b/libs/bookings/src/lib/booking-resource.loaders.ts @@ -56,8 +56,9 @@ export async function loadMetadataResources( id: d.id || d.map_id, zone: _.zone, })); + const id = use_region ? org.building?.parent_id : org.building?.id; + if (!id) return []; if (use_region) { - const id = org.building.parent_id; const buildings = org.buildings.filter((_) => _.parent_id === id); const lists = await Promise.all( buildings.map((_) => @@ -68,9 +69,7 @@ export async function loadMetadataResources( ); return flatten(lists); } - const data = await listChildMetadata(org.building.id, { - name: type, - }); + const data = await listChildMetadata(id, { name: type }); return flatten(data.map(map_metadata)); } diff --git a/libs/bookings/src/lib/booking-resource.utilities.ts b/libs/bookings/src/lib/booking-resource.utilities.ts index 421745ec52..9f85958ead 100644 --- a/libs/bookings/src/lib/booking-resource.utilities.ts +++ b/libs/bookings/src/lib/booking-resource.utilities.ts @@ -100,6 +100,28 @@ export function groupAvailability( return groups; } +/** + * Resources whose tags or homebase match the user's groups. + * Priority: both > homebase > tags > all available. + */ +export function preferredAllocationPool( + available: BookingAsset[], + user_groups: string[], +): BookingAsset[] { + if (!user_groups.length) return available; + const tagMatch = (asset: BookingAsset) => + !!asset.tags?.some((tag) => user_groups.includes(tag)); + const homebaseMatch = (asset: BookingAsset) => + !!asset.homebase && user_groups.includes(asset.homebase); + const tag_matched = available.filter(tagMatch); + const homebase_matched = available.filter(homebaseMatch); + const both_matched = tag_matched.filter(homebaseMatch); + if (both_matched.length) return both_matched; + if (homebase_matched.length) return homebase_matched; + if (tag_matched.length) return tag_matched; + return available; +} + /** Pick a random resource on the level with the most available resources. */ export function pickAutoAllocatedResource( available: BookingAsset[], diff --git a/libs/bookings/src/lib/booking.utilities.ts b/libs/bookings/src/lib/booking.utilities.ts index 86c9103f61..dd24ae2aba 100644 --- a/libs/bookings/src/lib/booking.utilities.ts +++ b/libs/bookings/src/lib/booking.utilities.ts @@ -134,6 +134,52 @@ export const visitorDisplayNameFor = (booking: Booking) => { return formatEmailName(asset_id || asset_name || 'Visitor'); }; +/** + * Location text shown for a booking/event: ` - `, falling back + * to whichever part is available. Shared by the booking card, booking details + * modal, and workplace schedule week/day views so they all read the same. + */ +export function bookingLocationString( + booking: Booking | CalendarEvent, + org: OrganisationService, +): string { + let location = ''; + let level_name = ''; + + if (booking instanceof Booking) { + location = + booking.booking_type === 'visitor' + ? booking.extension_data?.location || '' + : booking.location || booking.asset_name || ''; + // Unallocated parking has no space yet; hide the raw `unallocated-*` + // asset id that the location/asset name can fall back to. + if (location.startsWith('unallocated')) location = ''; + const level = org.levelWithID(booking.zones); + level_name = level?.display_name || level?.name || ''; + } else { + location = + booking.location || + booking.space?.display_name || + booking.space?.name || + booking.system?.name || + ''; + const system_level = org.levelWithID([ + ...(booking.system?.zones || []), + ]); + level_name = + booking.space?.level?.display_name || + booking.space?.level?.name || + system_level?.display_name || + system_level?.name || + ''; + } + + if (location && level_name) { + return `${location} - ${level_name}`; + } + return location || level_name || ''; +} + export async function findNearbyFeature( map_url: string, centered_at: Point | string, diff --git a/libs/bookings/src/lib/bookings.fn.ts b/libs/bookings/src/lib/bookings.fn.ts index ee8ec4a018..f142707bf5 100644 --- a/libs/bookings/src/lib/bookings.fn.ts +++ b/libs/bookings/src/lib/bookings.fn.ts @@ -29,10 +29,61 @@ import { import { addDays, addMinutes, + type Day, endOfDay, getUnixTime, + set, startOfDay, + startOfWeek, + subDays, } from 'date-fns'; +import { fromZonedTime, toZonedTime } from 'date-fns-tz'; + +export interface WaitlistWeekStart { + /** Day of the week the waitlist week starts on. `0` is Sunday */ + day?: number; + /** Hour of the day the waitlist week starts at */ + hour?: number; + /** Minute of the hour the waitlist week starts at */ + minute?: number; +} + +/** + * Whether a date falls within the currently active parking waitlist week. The + * week boundary is a configurable day and wall clock time, defaulting to + * Friday at 18:00. The boundary is applied in the building's timezone when + * building timezones are enabled, otherwise in the local timezone. + */ +export function isInWaitlistWeek( + date: number | Date, + building_timezone?: string, + week_start: WaitlistWeekStart = setting('app.parking.waitlist_week_start'), +) { + const day = week_start?.day ?? 5; + const hour = week_start?.hour ?? 18; + const minute = week_start?.minute ?? 0; + const timezone = + setting('app.bookings.use_building_timezone') || + setting('app.parking.use_building_timezone') + ? building_timezone + : ''; + const now = Date.now(); + const zoned_now = timezone ? toZonedTime(now, timezone) : new Date(now); + let current_week_start = set( + startOfWeek(zoned_now, { weekStartsOn: day as Day }), + { hours: hour, minutes: minute, seconds: 0, milliseconds: 0 }, + ); + if (zoned_now < current_week_start) { + current_week_start = subDays(current_week_start, 7); + } + const next_week_start = addDays(current_week_start, 7); + const asUTC = (value: Date) => + timezone ? fromZonedTime(value, timezone).valueOf() : value.valueOf(); + return ( + date.valueOf() >= asUTC(current_week_start) && + date.valueOf() < asUTC(next_week_start) + ); +} export interface BookingsQueryParams { /** Comma seperated list of zone ids to check availability */ diff --git a/libs/bookings/src/lib/desk-select-modal/desk-filters.component.ts b/libs/bookings/src/lib/desk-select-modal/desk-filters.component.ts index 69f778845c..e5adfaf119 100644 --- a/libs/bookings/src/lib/desk-select-modal/desk-filters.component.ts +++ b/libs/bookings/src/lib/desk-select-modal/desk-filters.component.ts @@ -90,12 +90,11 @@ import { BookingFormService } from '../booking-form.service'; @for (lvl of levels(); track lvl) { @@ -282,12 +281,15 @@ export class DeskFiltersComponent { () => !this.hide_levels() && this.levels().length > 1, ); - private readonly _clear_invalid_level = effect(() => { - const zone_id = this.options()?.zone_id; - if (!zone_id) return; + private readonly _clear_invalid_levels = effect(() => { + const zones = this.options()?.zones || []; + if (!zones.length) return; if (!this._state.resources().length) return; - if (!this.levels().some((lvl) => lvl.id === zone_id)) { - this._state.setOptions({ zone_id: undefined }); + const valid_zones = zones.filter((zone_id) => + this.levels().some((lvl) => lvl.id === zone_id), + ); + if (valid_zones.length !== zones.length) { + this._state.setOptions({ zones: valid_zones }); } }); diff --git a/libs/bookings/src/lib/desk-select-modal/desk-map.component.ts b/libs/bookings/src/lib/desk-select-modal/desk-map.component.ts index 2810541eb2..d7f301712a 100644 --- a/libs/bookings/src/lib/desk-select-modal/desk-map.component.ts +++ b/libs/bookings/src/lib/desk-select-modal/desk-map.component.ts @@ -7,6 +7,7 @@ import { model, output, signal, + untracked, WritableSignal, } from '@angular/core'; import { FormsModule } from '@angular/forms'; @@ -219,9 +220,13 @@ export class DeskMapComponent { }, {}); this.styles.set(styles); }); + // Show a level from the filter. Keep the shown level when the filter + // already has it, as the filter can hold more than one level. effect(() => { - const { zone_id } = this._state.options(); - const level = this._org.levelWithID([zone_id]); + const { zones, zone_id } = this._state.options(); + const level_ids = zones?.length ? zones : [zone_id]; + if (level_ids.includes(untracked(this.level)?.id)) return; + const level = this._org.levelWithID(level_ids); if (level) this.level.set(level); }); } @@ -231,7 +236,11 @@ export class DeskMapComponent { } public setLevel(level: BuildingLevel) { - this.setOptions({ zone_id: level?.id }); + // The filter reads `zones`, so set both to keep the two in step + this.setOptions({ + zones: level ? [level.id] : [], + zone_id: level?.id, + }); const bld = this._org.buildings.find((_) => _.id === level?.parent_id); if (bld) { const [latitude, longitude] = bld.location diff --git a/libs/bookings/src/lib/desk-select-modal/desk-select-modal.component.ts b/libs/bookings/src/lib/desk-select-modal/desk-select-modal.component.ts index 0d77c99db2..cb8d6bdb1a 100644 --- a/libs/bookings/src/lib/desk-select-modal/desk-select-modal.component.ts +++ b/libs/bookings/src/lib/desk-select-modal/desk-select-modal.component.ts @@ -7,7 +7,7 @@ import { } from '@angular/material/dialog'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { isMobileSafari, SettingsService } from '@placeos/common'; +import { isMobileSafari, SETTING_KEYS, SettingsService } from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; @@ -19,8 +19,6 @@ import { DeskFiltersComponent } from './desk-filters.component'; import { DeskListComponent } from './desk-list.component'; import { DeskMapComponent } from './desk-map.component'; -export const FAV_DESK_KEY = 'favourite_desks'; - @Component({ selector: 'desk-select-modal', template: ` @@ -254,7 +252,7 @@ export class DeskSelectModalComponent { false, ); public readonly favorites = signal( - this._settings.get(FAV_DESK_KEY) || [], + this._settings.get(SETTING_KEYS.FAVORITE_DESKS) || [], ); public readonly allow_multiple = computed( @@ -295,13 +293,16 @@ export class DeskSelectModalComponent { ); } - public toggleFavourite(item: BookingAsset) { - const fav_list = this.favorites(); + public toggleFavourite(item: BookingAsset | null) { + if (!item?.id) return; + const fav_list = + this._settings.get(SETTING_KEYS.FAVORITE_DESKS) || + this.favorites(); const new_state = !fav_list.includes(item.id); const next_favs = new_state ? [...fav_list, item.id] : fav_list.filter((_) => _ !== item.id); - this._settings.saveUserSetting(FAV_DESK_KEY, next_favs); this.favorites.set(next_favs); + this._settings.saveUserSetting(SETTING_KEYS.FAVORITE_DESKS, next_favs); } } diff --git a/libs/bookings/src/lib/locker-select-modal/locker-select-modal.component.ts b/libs/bookings/src/lib/locker-select-modal/locker-select-modal.component.ts index 127cf6ec7b..806a9db586 100644 --- a/libs/bookings/src/lib/locker-select-modal/locker-select-modal.component.ts +++ b/libs/bookings/src/lib/locker-select-modal/locker-select-modal.component.ts @@ -7,7 +7,7 @@ import { } from '@angular/material/dialog'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { isMobileSafari, SettingsService } from '@placeos/common'; +import { isMobileSafari, SETTING_KEYS, SettingsService } from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; @@ -20,8 +20,6 @@ import { LockerFiltersDisplayComponent } from './locker-filters-display.componen import { LockerFiltersComponent } from './locker-filters.component'; import { LockerMapComponent } from './locker-map.component'; -export const FAV_LOCKER_KEY = 'favourite_lockers'; - @Component({ selector: 'locker-select-modal', template: ` @@ -227,7 +225,7 @@ export class LockerSelectModalComponent { .join(','), ); public readonly favorites = signal( - this._settings.get(FAV_LOCKER_KEY) || [], + this._settings.get(SETTING_KEYS.FAVORITE_LOCKERS) || [], ); constructor() { @@ -262,6 +260,9 @@ export class LockerSelectModalComponent { ? [...fav_list, item.id] : fav_list.filter((_) => _ !== item.id); this.favorites.set(next_favs); - this._settings.saveUserSetting(FAV_LOCKER_KEY, next_favs); + this._settings.saveUserSetting( + SETTING_KEYS.FAVORITE_LOCKERS, + next_favs, + ); } } diff --git a/libs/bookings/src/lib/parking-select-modal/parking-select-modal.component.ts b/libs/bookings/src/lib/parking-select-modal/parking-select-modal.component.ts index 294b76f74a..f266b0b679 100644 --- a/libs/bookings/src/lib/parking-select-modal/parking-select-modal.component.ts +++ b/libs/bookings/src/lib/parking-select-modal/parking-select-modal.component.ts @@ -7,7 +7,7 @@ import { } from '@angular/material/dialog'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { isMobileSafari, SettingsService } from '@placeos/common'; +import { isMobileSafari, SETTING_KEYS, SettingsService } from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; @@ -20,8 +20,6 @@ import { ParkingFiltersComponent } from './parking-filters.component'; import { ParkingListComponent } from './parking-list.component'; import { ParkingMapComponent } from './parking-map.component'; -export const FAV_PARKING_KEY = 'favourite_parking_spaces'; - @Component({ selector: 'parking-select-modal', template: ` @@ -215,7 +213,8 @@ export class ParkingSelectModalComponent { .join(','), ); public readonly favorites = signal( - this._settings.get(FAV_PARKING_KEY) || [], + this._settings.get(SETTING_KEYS.FAVORITE_PARKING_SPACES) || + [], ); constructor() { @@ -245,6 +244,9 @@ export class ParkingSelectModalComponent { ? [...fav_list, item.id] : fav_list.filter((_) => _ !== item.id); this.favorites.set(next_favs); - this._settings.saveUserSetting(FAV_PARKING_KEY, next_favs); + this._settings.saveUserSetting( + SETTING_KEYS.FAVORITE_PARKING_SPACES, + next_favs, + ); } } diff --git a/libs/bookings/src/lib/parking-space-list-field.component.ts b/libs/bookings/src/lib/parking-space-list-field.component.ts index ee74edfc60..21fa9295b5 100644 --- a/libs/bookings/src/lib/parking-space-list-field.component.ts +++ b/libs/bookings/src/lib/parking-space-list-field.component.ts @@ -3,15 +3,13 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { MatRippleModule } from '@angular/material/core'; +import { SETTING_KEYS } from '@placeos/common'; import { SettingsService } from 'libs/common/src/lib/settings.service'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { BookingAsset } from './booking-form.types'; -import { - FAV_PARKING_KEY, - ParkingSelectModalComponent, -} from './parking-select-modal/parking-select-modal.component'; +import { ParkingSelectModalComponent } from './parking-select-modal/parking-select-modal.component'; const EMPTY_FAVS: string[] = []; @@ -153,7 +151,7 @@ export class ParkingSpaceListFieldComponent implements ControlValueAccessor { private _onTouch: (_: BookingAsset[]) => void; public readonly favorites = this._settings.signal( - FAV_PARKING_KEY, + SETTING_KEYS.FAVORITE_PARKING_SPACES, EMPTY_FAVS, true, ); @@ -206,13 +204,13 @@ export class ParkingSpaceListFieldComponent implements ControlValueAccessor { const fav_list = this.favorites() || EMPTY_FAVS; const new_state = !fav_list.includes(space.id); if (new_state) { - this._settings.saveUserSetting(FAV_PARKING_KEY, [ - ...fav_list, - space.id, - ]); + this._settings.saveUserSetting( + SETTING_KEYS.FAVORITE_PARKING_SPACES, + [...fav_list, space.id], + ); } else { this._settings.saveUserSetting( - FAV_PARKING_KEY, + SETTING_KEYS.FAVORITE_PARKING_SPACES, fav_list.filter((_) => _ !== space.id), ); } diff --git a/libs/bookings/src/lib/visitor-invite-form.component.ts b/libs/bookings/src/lib/visitor-invite-form.component.ts index 981080d446..cd023b52cf 100644 --- a/libs/bookings/src/lib/visitor-invite-form.component.ts +++ b/libs/bookings/src/lib/visitor-invite-form.component.ts @@ -107,7 +107,7 @@ import { BookingFormService } from './booking-form.service'; [ngModelOptions]="{ standalone: true }" [use_24hr]="use_24hr()" [range]="bookable_hours()" - [min_duration]="min_duration()" + [min_duration]="effective_min_duration()" [timezone]="timezone()" >
@@ -120,6 +120,9 @@ import { BookingFormService } from './booking-form.service'; [formField]="form.duration" [time]="model().date" [max]="max_duration()" + [min]="min_duration()" + [step]="duration_step()" + [custom_options]="custom_duration_options()" [use_24hr]="use_24hr()" [end_time]="bookable_hours()?.end" [timezone]="timezone()" @@ -228,19 +231,39 @@ import { BookingFormService } from './booking-form.service'; }
-
- - - - +
+
+ + + + +
+
+ + + + +
} @else {
@@ -411,6 +434,16 @@ export class VisitorInviteFormComponent () => this._visitor_max_duration() || this._booking_max_duration() || 180, ); + public readonly duration_step = computed( + () => + settingSignal('visitors.duration_step')() || + settingSignal('bookings.duration_step', 15)(), + ); + public readonly custom_duration_options = computed( + () => + settingSignal('visitors.custom_duration_options')() || + settingSignal('bookings.custom_duration_options', [])(), + ); public readonly multiple = settingSignal( 'bookings.multiple_visitors', false, @@ -439,6 +472,9 @@ export class VisitorInviteFormComponent () => this._visitor_min_duration() || this._booking_min_duration() || 30, ); + public readonly effective_min_duration = computed(() => + Math.min(this.min_duration(), ...this.custom_duration_options()), + ); public readonly buildings = this._org.active_buildings; public readonly building = computed(() => settingSignal('use_region', false)() diff --git a/libs/bookings/src/lib/visitor-invite-success.component.ts b/libs/bookings/src/lib/visitor-invite-success.component.ts index dfb95c203c..3e56feb21d 100644 --- a/libs/bookings/src/lib/visitor-invite-success.component.ts +++ b/libs/bookings/src/lib/visitor-invite-success.component.ts @@ -54,7 +54,10 @@ import { BookingFormService } from './booking-form.service';

{{ - 'BOOKINGS.VISITOR_SENT_MSG' + (multiple() && last_count() > 1 + ? 'BOOKINGS.VISITOR_SENT_MSG_MULTIPLE' + : 'BOOKINGS.VISITOR_SENT_MSG' + ) | translate : { location: @@ -129,7 +132,12 @@ import { BookingFormService } from './booking-form.service'; class="flex-1" (click)="another.emit()" > - {{ 'BOOKINGS.VISITOR_BOOK_ANOTHER' | translate }} + {{ + (multiple() && last_count() > 1 + ? 'BOOKINGS.VISITOR_BOOK_ANOTHER_MULTIPLE' + : 'BOOKINGS.VISITOR_BOOK_ANOTHER' + ) | translate + }}

diff --git a/libs/bookings/src/test/booking-card.component.spec.ts b/libs/bookings/src/test/booking-card.component.spec.ts index c4560950a6..c96634cfb2 100644 --- a/libs/bookings/src/test/booking-card.component.spec.ts +++ b/libs/bookings/src/test/booking-card.component.spec.ts @@ -238,6 +238,31 @@ describe('BookingCardComponent', () => { expect(spectator.component.status()).toBe('warning'); }); + it('should hide the asset id of unallocated parking bookings', () => { + spectator.setInput({ + booking: new Booking({ + booking_type: 'parking', + type: 'parking', + asset_id: 'unallocated-123', + } as any), + }); + + expect(spectator.component.resource_label()).toBe('RESOURCE.PARKING'); + }); + + it('should show selected parking space when allocated', () => { + spectator.setInput({ + booking: new Booking({ + booking_type: 'parking', + type: 'parking', + asset_id: 'parking-1', + asset_name: 'Parking 1', + } as any), + }); + + expect(spectator.component.resource_label()).toBe('Parking 1'); + }); + it('should hide selected parking space when enabled', () => { settings_service.get.mockImplementation((name: string) => name === 'app.parking.hide_selected_space' ? true : undefined, diff --git a/libs/bookings/src/test/booking-details-modal.component.spec.ts b/libs/bookings/src/test/booking-details-modal.component.spec.ts index b4452de102..0a29623876 100644 --- a/libs/bookings/src/test/booking-details-modal.component.spec.ts +++ b/libs/bookings/src/test/booking-details-modal.component.spec.ts @@ -270,6 +270,25 @@ describe('BookingDetailsModalComponent', () => { } }); + it('should hide all actions for a cancelled booking', () => { + const booking = new Booking({ + id: 'booking-2', + booking_type: 'desk', + asset_id: 'desk-2', + date: Date.now() + 5 * 60 * 1000, + duration: 60, + status: 'approved', + }); + spectator.component.booking.set(booking); + spectator.detectChanges(); + expect('[actions]').toExist(); + spectator.component.booking.set( + new Booking({ ...booking, status: 'cancelled' } as any), + ); + spectator.detectChanges(); + expect('[actions]').not.toExist(); + }); + it('should show cancel series for a recurring booking by default', () => { spectator.component.booking.set( new Booking({ diff --git a/libs/bookings/src/test/booking-form.service.spec.ts b/libs/bookings/src/test/booking-form.service.spec.ts index 520772f097..78c7d16ec4 100644 --- a/libs/bookings/src/test/booking-form.service.spec.ts +++ b/libs/bookings/src/test/booking-form.service.spec.ts @@ -201,6 +201,42 @@ describe('BookingFormService', () => { expect(ts_client.listChildMetadata).not.toHaveBeenCalled(); }); + it('should use the current form when listing available resources', async () => { + const org = spectator.inject(OrganisationService) as any; + org.active_region = signal({ id: 'reg-1' }); + org.regions = []; + org.settings = []; + (spectator.inject(SettingsService).overrides as any).set([{}, {}]); + const desk = { + id: 'desk-1', + name: 'Desk 1', + zone: { id: 'lvl-1', parent_id: 'bld-1' }, + features: [], + }; + vi.mocked(ts_client.listChildMetadata).mockResolvedValue([ + { + metadata: { desks: { details: [desk] } }, + zone: desk.zone, + }, + ] as any); + (spectator.service as any)._resources_resource.set([desk]); + (spectator.service as any)._booking_rules_resource.set({}); + (spectator.service as any)._available_resource.set([]); + spectator.service.setOptions({ type: 'desk', zone_id: 'lvl-1' }); + spectator.service.model.update((form) => ({ + ...form, + booking_type: 'desk', + date: Date.now() + 60 * 60 * 1000, + duration: 60, + all_day: false, + user: currentUser(), + })); + + const resources = await spectator.service.listAvailableResources(); + + expect(resources.map(({ id }) => id)).toEqual(['desk-1']); + }); + it('should load desk resources from assets when enabled', async () => { vi.mocked(spectator.inject(SettingsService).get).mockImplementation( (key: string) => key === 'app.desks.use_assets', @@ -348,6 +384,30 @@ describe('BookingFormService', () => { expect(show_user).not.toHaveBeenCalled(); }); + it('should use the all-day period for the booking window', () => { + (spectator.inject(SettingsService).get as Mock).mockImplementation( + (key: string) => + key === 'app.bookings.all_day_period' + ? { start: 8, end: 18 } + : undefined, + ); + const date = new Date(2026, 8, 23, 13, 30).valueOf(); + + expect(spectator.service.bookingWindow({ date, duration: 60 })).toEqual( + { start: date, end: date + 60 * 60 * 1000 }, + ); + expect( + spectator.service.bookingWindow({ + date, + duration: 60, + all_day: true, + }), + ).toEqual({ + start: new Date(2026, 8, 23, 8).valueOf(), + end: new Date(2026, 8, 23, 18).valueOf(), + }); + }); + it('should handle view changes', () => { expect(spectator.service.view()).toBe('form'); spectator.service.setView('map'); @@ -377,6 +437,50 @@ describe('BookingFormService', () => { spy.mockRestore(); }); + it('should not refresh availability when the selected desk changes', async () => { + vi.useFakeTimers(); + try { + const form_value = (spectator.service as any)._form_value; + form_value.set({ + date: Date.now(), + duration: 60, + resources: [], + asset_id: '', + }); + TestBed.tick(); + await vi.advanceTimersByTimeAsync(500); + TestBed.tick(); + const availability_form = (spectator.service as any) + ._form_value_debounced; + const initial_value = availability_form.value(); + const desk = { + id: 'desk-1', + name: 'Desk 1', + map_id: 'map-desk-1', + zone: { id: 'level-1', parent_id: 'building-1' }, + }; + + form_value.set({ + ...initial_value, + resources: [desk], + booking_asset: desk, + asset_id: desk.id, + asset_name: desk.name, + map_id: desk.map_id, + name: desk.name, + description: desk.name, + zones: [desk.zone.parent_id, desk.zone.id], + }); + TestBed.tick(); + await vi.advanceTimersByTimeAsync(500); + TestBed.tick(); + + expect(availability_form.value()).toBe(initial_value); + } finally { + vi.useRealTimers(); + } + }); + it('should not update asset options when form date and duration are unchanged', () => { const asset_state = spectator.inject(AssetStateService); spectator.service.newForm('desk'); @@ -684,6 +788,16 @@ describe('BookingFormService', () => { spy.mockRestore(); }); + it('should clear form on a visit to the landing page', () => { + const spy = vi.spyOn(spectator.service, 'clearForm'); + const router = spectator.inject(Router); + (router.events as any).next( + new NavigationEnd(1, '/landing', '/landing'), + ); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + it('should show user friendly names for invalid form fields', async () => { spectator.service.newForm('desk'); spectator.service.model.update((m) => ({ ...m, asset_id: '' })); @@ -2886,7 +3000,7 @@ describe('BookingFormService', () => { expect(post_form).toHaveBeenNthCalledWith(1, true, false); expect(post_form).toHaveBeenNthCalledWith(2, true, false); expect(clear_form).toHaveBeenCalledTimes(1); - expect(saved_forms[0].zones).toEqual(['org-1', 'bld-1']); + expect(saved_forms[0].zones).toEqual(['org-1', 'reg-1', 'bld-1']); expect(saved_forms[1].location).toBe('Main Lobby'); }); diff --git a/libs/bookings/src/test/booking.fn.spec.ts b/libs/bookings/src/test/booking.fn.spec.ts index 4d6c09068a..8bb1db8b7f 100644 --- a/libs/bookings/src/test/booking.fn.spec.ts +++ b/libs/bookings/src/test/booking.fn.spec.ts @@ -17,6 +17,7 @@ import { checkinBooking, createBooking, createBookingsForEvent, + isInWaitlistWeek, queryAllBookings, queryBookings, queryResourceAvailability, @@ -847,6 +848,35 @@ describe('[Booking API]', () => { }); }); + describe('isInWaitlistWeek', () => { + // Local wall-clock dates so the assertions hold in any timezone + const friday_17_59 = new Date(2026, 6, 31, 17, 59).valueOf(); + const friday_18_00 = new Date(2026, 6, 31, 18, 0).valueOf(); + const next_monday = new Date(2026, 7, 3, 8, 0).valueOf(); + + afterEach(() => vi.restoreAllMocks()); + + it('should exclude next week before the default Friday 18:00 cutoff', () => { + vi.spyOn(Date, 'now').mockReturnValue(friday_17_59); + expect(isInWaitlistWeek(next_monday)).toBe(false); + }); + + it('should include next week once the cutoff is reached', () => { + vi.spyOn(Date, 'now').mockReturnValue(friday_18_00); + expect(isInWaitlistWeek(next_monday)).toBe(true); + }); + + it('should use the configured week boundary', () => { + const week_start = { day: 3, hour: 9, minute: 30 }; + const thursday = new Date(2026, 6, 30, 8, 0).valueOf(); + const now_spy = vi.spyOn(Date, 'now'); + now_spy.mockReturnValue(new Date(2026, 6, 29, 9, 29).valueOf()); + expect(isInWaitlistWeek(thursday, '', week_start)).toBe(false); + now_spy.mockReturnValue(new Date(2026, 6, 29, 9, 30).valueOf()); + expect(isInWaitlistWeek(thursday, '', week_start)).toBe(true); + }); + }); + describe('checkinBooking', () => { it('should allow calling POST request for checking in a booking', async () => { const spy = vi.spyOn(ts_client, 'post'); diff --git a/libs/bookings/src/test/booking.utilities.spec.ts b/libs/bookings/src/test/booking.utilities.spec.ts index 949f87ebf0..c0db947739 100644 --- a/libs/bookings/src/test/booking.utilities.spec.ts +++ b/libs/bookings/src/test/booking.utilities.spec.ts @@ -2,15 +2,18 @@ import { Injector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Booking, + BuildingLevel, CalendarEvent, fromBookingRecurrence, getAllDayTimeRange, + OrganisationService, Space, User, WeekOfMonth, } from '@placeos/common'; import { generateBookingForm } from '../lib/booking-form.model'; import { + bookingLocationString, loadLockersForScope, newBookingFromCalendarEvent, parkingRequestStatus, @@ -19,6 +22,7 @@ import { vi.mock('@placeos/ts-client', { spy: true }); import * as ts_client from '@placeos/ts-client'; +import { PlaceSystem } from '@placeos/ts-client'; describe('Booking Utilities', () => { let injector: Injector; @@ -27,6 +31,37 @@ describe('Booking Utilities', () => { injector = TestBed.inject(Injector); }); + describe('bookingLocationString', () => { + const system_level = new BuildingLevel({ id: 'lvl-2', name: 'L2' }); + const system = new PlaceSystem({ id: 'sys-1', zones: ['lvl-2'] }); + const org = { + levelWithID: (ids: string[]) => + ids.includes('lvl-2') ? system_level : undefined, + } as OrganisationService; + + it('should use the level name of the space', () => { + const event = new CalendarEvent({ + location: 'Room 1', + resources: [ + new Space({ + email: 'room-1@example.com', + level: new BuildingLevel({ name: 'Level 1' }), + }), + ], + system, + }); + expect(bookingLocationString(event, org)).toBe('Room 1 - Level 1'); + }); + + it('should fall back to the level of the system zones', () => { + const event = new CalendarEvent({ + location: 'Room 1', + system, + }); + expect(bookingLocationString(event, org)).toBe('Room 1 - L2'); + }); + }); + describe('parkingRequestStatus', () => { it('should identify waitlisted parking requests', () => { expect( diff --git a/libs/bookings/src/test/desk-filters.component.spec.ts b/libs/bookings/src/test/desk-filters.component.spec.ts index 55cc097641..b167841070 100644 --- a/libs/bookings/src/test/desk-filters.component.spec.ts +++ b/libs/bookings/src/test/desk-filters.component.spec.ts @@ -7,7 +7,7 @@ import { MatSelectModule } from '@angular/material/select'; import { Spectator, createComponentFactory } from '@ngneat/spectator/vitest'; import { Booking, OrganisationService, SettingsService } from '@placeos/common'; import { createSettingsServiceMock } from '@placeos/common/tests'; -import { MockComponent, MockModule, MockPipe } from 'ng-mocks'; +import { MockComponent, MockModule, MockPipe, ngMocks } from 'ng-mocks'; import type { Mock } from 'vitest'; import { BuildingPipe } from 'libs/components/src/lib/building.pipe'; @@ -134,7 +134,7 @@ describe('DeskFiltersComponent', () => { beforeEach(() => { vi.clearAllMocks(); - options.set({ zone_id: 'lvl-a', show_fav: false, features: [] }); + options.set({ zones: ['lvl-a'], show_fav: false, features: [] }); features.set(['sit-stand', 'monitor']); resources.set([ { id: 'd1', zone: { id: 'lvl-a' } }, @@ -159,6 +159,20 @@ describe('DeskFiltersComponent', () => { expect(spectator.query('mat-select[name="location"]')).toExist(); }); + it('should allow multiple levels to be selected', () => { + expect(ngMocks.input('mat-select[name="location"]', 'multiple')).toBe( + true, + ); + spectator.triggerEventHandler( + 'mat-select[name="location"]', + 'ngModelChange', + ['lvl-a', 'lvl-b'], + ); + expect(set_options).toHaveBeenCalledWith({ + zones: ['lvl-a', 'lvl-b'], + }); + }); + it('should hide favourites and feature sections when levels are hidden', () => { spectator.setInput('hide_levels', true); spectator.detectChanges(); @@ -190,8 +204,8 @@ describe('DeskFiltersComponent', () => { }); it('should forward option and feature changes to the booking form service', () => { - spectator.component.setOptions({ zone_id: 'lvl-b' }); - expect(set_options).toHaveBeenCalledWith({ zone_id: 'lvl-b' }); + spectator.component.setOptions({ zones: ['lvl-b'] }); + expect(set_options).toHaveBeenCalledWith({ zones: ['lvl-b'] }); spectator.component.setFeature('monitor', true); expect(set_feature).toHaveBeenCalledWith('monitor', true); }); @@ -203,18 +217,18 @@ describe('DeskFiltersComponent', () => { expect(org_mock.region).toEqual({ id: 'reg-2' }); }); - it('should clear the selected level when it is no longer valid', () => { + it('should clear selected levels that are no longer valid', () => { resources.set([{ id: 'd2', zone: { id: 'lvl-b' } }]); - options.set({ zone_id: 'lvl-a' }); + options.set({ zones: ['lvl-a', 'lvl-b'] }); spectator.detectChanges(); - expect(set_options).toHaveBeenCalledWith({ zone_id: undefined }); + expect(set_options).toHaveBeenCalledWith({ zones: ['lvl-b'] }); }); - it('should retain the selected level while resources are loading', () => { + it('should retain the selected levels while resources are loading', () => { resources.set([]); - options.set({ zone_id: 'lvl-a' }); + options.set({ zones: ['lvl-a', 'lvl-b'] }); spectator.detectChanges(); - expect(set_options).not.toHaveBeenCalledWith({ zone_id: undefined }); + expect(set_options).not.toHaveBeenCalled(); }); it('should only allow all-day bookings when both settings are enabled', () => { diff --git a/libs/bookings/src/test/desk-map.component.spec.ts b/libs/bookings/src/test/desk-map.component.spec.ts index cfb3a82675..7ac1a5326a 100644 --- a/libs/bookings/src/test/desk-map.component.spec.ts +++ b/libs/bookings/src/test/desk-map.component.spec.ts @@ -53,7 +53,9 @@ describe('DeskMapComponent', () => { active_building: signal({ id: 'bld-1' }), levelsForRegion: vi.fn(() => levels), levelsForBuilding: vi.fn(() => levels), - levelWithID: vi.fn(() => undefined), + levelWithID: vi.fn( + (_ids: string[]): (typeof levels)[number] | undefined => undefined, + ), buildings: [{ id: 'bld-1', location: '1.23,4.56' }], }; @@ -122,7 +124,10 @@ describe('DeskMapComponent', () => { 'ngModelChange', levels[1], ); - expect(set_options).toHaveBeenCalledWith({ zone_id: 'lvl-2' }); + expect(set_options).toHaveBeenCalledWith({ + zones: ['lvl-2'], + zone_id: 'lvl-2', + }); expect(spectator.component.level()).toEqual(levels[1]); expect(spectator.component.coordinates()).toEqual({ latitude: 1.23, @@ -130,6 +135,15 @@ describe('DeskMapComponent', () => { }); }); + it('should show a level that the filter has selected', () => { + org_mock.levelWithID.mockImplementationOnce((ids) => + levels.find((lvl) => ids.includes(lvl.id)), + ); + options.set({ zones: ['lvl-2'] }); + spectator.detectChanges(); + expect(spectator.component.level()?.id).toBe('lvl-2'); + }); + it('should build map actions from the available resources', () => { available_resources.set([{ id: 'd1', map_id: 'm1' }, { id: 'd2' }]); spectator.detectChanges(); diff --git a/libs/bookings/src/test/invite-visitor-form.component.spec.ts b/libs/bookings/src/test/invite-visitor-form.component.spec.ts index 915ae23a1b..effbbe176d 100644 --- a/libs/bookings/src/test/invite-visitor-form.component.spec.ts +++ b/libs/bookings/src/test/invite-visitor-form.component.spec.ts @@ -53,19 +53,71 @@ describe('InviteVisitorFormComponent', () => { }); }, }, - MockProvider(OrganisationService as any, { - initialised: signal(true), - building_list: signal([]), - active_buildings: signal([ - { id: 'bld-1', name: 'Building One' }, - { id: 'bld-2', name: 'Building Two' }, - ]), - buildings: [ - { id: 'bld-1', name: 'Building One' }, - { id: 'bld-2', name: 'Building Two' }, - ], - building: { id: 'bld-1', name: 'Building One' }, - }), + MockProvider( + OrganisationService as any, + { + initialised: signal(true), + active_buildings: signal([ + { + id: 'bld-1', + name: 'Building One', + parent_id: 'reg-1', + zone_id: 'zone-bld-1', + }, + ]), + building_list: signal([ + { + id: 'bld-1', + name: 'Building One', + parent_id: 'reg-1', + zone_id: 'zone-bld-1', + }, + { + id: 'bld-2', + name: 'Building Two', + parent_id: 'reg-1', + zone_id: 'zone-bld-2', + }, + ]), + buildings: [ + { + id: 'bld-1', + name: 'Building One', + parent_id: 'reg-1', + zone_id: 'zone-bld-1', + }, + { + id: 'bld-2', + name: 'Building Two', + parent_id: 'reg-1', + zone_id: 'zone-bld-2', + }, + ], + building: { id: 'bld-1', name: 'Building One' }, + organisation: { id: 'org-1' }, + find: vi.fn((id: string) => + [ + { + id: 'bld-1', + name: 'Building One', + parent_id: 'reg-1', + zone_id: 'zone-bld-1', + }, + { + id: 'bld-2', + name: 'Building Two', + parent_id: 'reg-1', + zone_id: 'zone-bld-2', + }, + ].find((building) => building.id === id), + ), + levelWithID: vi.fn((id_list: string[]) => + id_list?.includes('lvl-2') + ? { id: 'lvl-2', parent_id: 'bld-2' } + : null, + ), + } as any, + ), MockProvider(SettingsService, createSettingsServiceMock()), ], imports: [ @@ -96,7 +148,57 @@ describe('InviteVisitorFormComponent', () => { spectator.component.setBuilding('bld-1'); - expect(service.model().zones).toEqual(['bld-1']); + expect(service.model().zones).toEqual(['org-1', 'reg-1', 'bld-1']); + }); + + it('should list all buildings when editing a booking outside the active building context', async () => { + const buildings = spectator.component.buildings() || []; + + expect(buildings.map((building) => building.id)).toEqual([ + 'bld-1', + 'bld-2', + ]); + }); + + it('should resolve selected building from level zones when editing visitors', async () => { + const service = spectator.inject(BookingFormService); + await spectator.component.ngOnInit(); + service.model.update((m) => ({ + ...m, + id: 'booking-edit', + booking_type: 'visitor', + zones: ['org-1', 'reg-1', 'lvl-2'], + })); + + expect(spectator.component.selected_building_id()).toBe('bld-2'); + }); + + it('should resolve selected building from building zone ids when editing visitors', async () => { + const service = spectator.inject(BookingFormService); + await spectator.component.ngOnInit(); + service.model.update((m) => ({ + ...m, + id: 'booking-edit', + booking_type: 'visitor', + zones: ['org-1', 'reg-1', 'zone-bld-2'], + })); + + expect(spectator.component.selected_building_id()).toBe('bld-2'); + }); + + it('should not switch the active building when editing a visitor in another building', async () => { + const service = spectator.inject(BookingFormService); + const org = spectator.inject(OrganisationService); + service.model.update((m) => ({ + ...m, + id: 'booking-edit', + booking_type: 'visitor', + zones: ['org-1', 'reg-1', 'bld-2'], + })); + + await spectator.component.ngOnInit(); + + expect(org.building.id).toBe('bld-1'); }); it('should contain form fields', () => { @@ -680,6 +782,33 @@ describe('InviteVisitorFormComponent', () => { expect(service.model().date).toBe(booking_date); }); + it('should restore preloaded edit booking zones from the booking service when the form is initially empty', async () => { + const service = spectator.inject(BookingFormService); + const settings = spectator.inject(SettingsService); + (settings.get as any).mockImplementation((key: string) => + key === 'app.bookings.multiple_visitors' ? false : undefined, + ); + sessionStorage.removeItem('PLACEOS.booking_form'); + sessionStorage.removeItem('PLACEOS.booking_form_filters'); + (service as any).booking = new Booking({ + id: 'booking-edit-zones-only', + type: 'visitor', + booking_type: ' ', + date: Date.now(), + duration: 60, + asset_id: 'visitor@example.com', + asset_name: 'Visitor', + zones: ['org-1', 'reg-1', 'zone-bld-2'], + }); + service.form().reset(); + + await spectator.component.ngOnInit(); + + expect(service.model().id).toBe('booking-edit-zones-only'); + expect(service.model().zones).toEqual(['org-1', 'reg-1', 'zone-bld-2']); + expect(spectator.component.selected_building_id()).toBe('bld-2'); + }); + it('should read bookable_hours from visitor settings with fallback', () => { const settings = spectator.inject(SettingsService); (settings.get as Mock).mockImplementation((key: string) => { diff --git a/libs/bookings/src/test/locker-select-modal.component.spec.ts b/libs/bookings/src/test/locker-select-modal.component.spec.ts index a1407864b5..a312b8dc48 100644 --- a/libs/bookings/src/test/locker-select-modal.component.spec.ts +++ b/libs/bookings/src/test/locker-select-modal.component.spec.ts @@ -5,7 +5,7 @@ import { MatDialogRef, } from '@angular/material/dialog'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { SettingsService } from '@placeos/common'; +import { SETTING_KEYS, SettingsService } from '@placeos/common'; import { createSettingsServiceMock } from '@placeos/common/tests'; import { MockComponent, MockModule } from 'ng-mocks'; @@ -16,10 +16,7 @@ import { LockerBankListComponent } from '../lib/locker-select-modal/locker-bank- import { LockerFiltersDisplayComponent } from '../lib/locker-select-modal/locker-filters-display.component'; import { LockerFiltersComponent } from '../lib/locker-select-modal/locker-filters.component'; import { LockerMapComponent } from '../lib/locker-select-modal/locker-map.component'; -import { - FAV_LOCKER_KEY, - LockerSelectModalComponent, -} from '../lib/locker-select-modal/locker-select-modal.component'; +import { LockerSelectModalComponent } from '../lib/locker-select-modal/locker-select-modal.component'; describe('LockerSelectModalComponent', () => { let spectator: Spectator; @@ -116,9 +113,10 @@ describe('LockerSelectModalComponent', () => { expect(spectator.component.favorites()).toEqual([]); spectator.component.toggleFavourite({ id: 'locker-1' } as any); expect(spectator.component.favorites()).toEqual(['locker-1']); - expect(settings.saveUserSetting).toHaveBeenCalledWith(FAV_LOCKER_KEY, [ - 'locker-1', - ]); + expect(settings.saveUserSetting).toHaveBeenCalledWith( + SETTING_KEYS.FAVORITE_LOCKERS, + ['locker-1'], + ); spectator.component.toggleFavourite({ id: 'locker-1' } as any); expect(spectator.component.favorites()).toEqual([]); }); diff --git a/libs/bookings/src/test/parking-select-modal.component.spec.ts b/libs/bookings/src/test/parking-select-modal.component.spec.ts index 931a215528..bc37c8378b 100644 --- a/libs/bookings/src/test/parking-select-modal.component.spec.ts +++ b/libs/bookings/src/test/parking-select-modal.component.spec.ts @@ -6,7 +6,7 @@ import { } from '@angular/material/dialog'; import { MatTooltipModule } from '@angular/material/tooltip'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { SettingsService } from '@placeos/common'; +import { SETTING_KEYS, SettingsService } from '@placeos/common'; import { createSettingsServiceMock } from '@placeos/common/tests'; import { MockComponent, MockModule, MockProvider } from 'ng-mocks'; @@ -18,10 +18,7 @@ import { ParkingFiltersDisplayComponent } from '../lib/parking-select-modal/park import { ParkingFiltersComponent } from '../lib/parking-select-modal/parking-filters.component'; import { ParkingListComponent } from '../lib/parking-select-modal/parking-list.component'; import { ParkingMapComponent } from '../lib/parking-select-modal/parking-map.component'; -import { - FAV_PARKING_KEY, - ParkingSelectModalComponent, -} from '../lib/parking-select-modal/parking-select-modal.component'; +import { ParkingSelectModalComponent } from '../lib/parking-select-modal/parking-select-modal.component'; const asset = (id: string): BookingAsset => ({ id, name: id, bookable: true, features: [] }) as BookingAsset; @@ -115,9 +112,10 @@ describe('ParkingSelectModalComponent', () => { expect(spectator.component.favorites()).toEqual([]); spectator.component.toggleFavourite(asset('a')); expect(spectator.component.favorites()).toEqual(['a']); - expect(settings.saveUserSetting).toHaveBeenCalledWith(FAV_PARKING_KEY, [ - 'a', - ]); + expect(settings.saveUserSetting).toHaveBeenCalledWith( + SETTING_KEYS.FAVORITE_PARKING_SPACES, + ['a'], + ); spectator.component.toggleFavourite(asset('a')); expect(spectator.component.favorites()).toEqual([]); }); diff --git a/libs/bookings/src/test/parking-space-list-field.component.spec.ts b/libs/bookings/src/test/parking-space-list-field.component.spec.ts index 5f72878e7f..d5870a65a2 100644 --- a/libs/bookings/src/test/parking-space-list-field.component.spec.ts +++ b/libs/bookings/src/test/parking-space-list-field.component.spec.ts @@ -1,5 +1,6 @@ import { MatDialog } from '@angular/material/dialog'; import { Spectator, createComponentFactory } from '@ngneat/spectator/vitest'; +import { SETTING_KEYS } from '@placeos/common'; import { createSettingsServiceMock } from '@placeos/common/tests'; import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; import { of } from 'rxjs'; @@ -9,7 +10,6 @@ import { SettingsService } from 'libs/common/src/lib/settings.service'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { FAV_PARKING_KEY } from '../lib/parking-select-modal/parking-select-modal.component'; import { ParkingSpaceListFieldComponent } from '../lib/parking-space-list-field.component'; describe('ParkingSpaceListFieldComponent', () => { @@ -99,21 +99,25 @@ describe('ParkingSpaceListFieldComponent', () => { it('should add a space to favourites when toggled on', () => { const settings = spectator.inject(SettingsService); spectator.component.toggleFavourite({ id: 'space-1' } as any); - expect(settings.saveUserSetting).toHaveBeenCalledWith(FAV_PARKING_KEY, [ - 'space-1', - ]); + expect(settings.saveUserSetting).toHaveBeenCalledWith( + SETTING_KEYS.FAVORITE_PARKING_SPACES, + ['space-1'], + ); }); it('should remove a space from favourites when toggled off', () => { const settings = spectator.inject(SettingsService); (settings.get as Mock).mockImplementation((key: string) => - key === FAV_PARKING_KEY ? ['space-1', 'space-2'] : undefined, + key === SETTING_KEYS.FAVORITE_PARKING_SPACES + ? ['space-1', 'space-2'] + : undefined, ); spectator.detectChanges(); spectator.component.toggleFavourite({ id: 'space-1' } as any); - expect(settings.saveUserSetting).toHaveBeenCalledWith(FAV_PARKING_KEY, [ - 'space-2', - ]); + expect(settings.saveUserSetting).toHaveBeenCalledWith( + SETTING_KEYS.FAVORITE_PARKING_SPACES, + ['space-2'], + ); }); it('should reflect disabled state changes', () => { diff --git a/libs/bookings/src/test/visitor-invite-form.component.spec.ts b/libs/bookings/src/test/visitor-invite-form.component.spec.ts index a261d0016e..13fd6a0883 100644 --- a/libs/bookings/src/test/visitor-invite-form.component.spec.ts +++ b/libs/bookings/src/test/visitor-invite-form.component.spec.ts @@ -12,6 +12,7 @@ import { import { Booking, OrganisationService, + settingSignal, SettingsService, User, } from '@placeos/common'; @@ -73,9 +74,31 @@ describe('VisitorInviteFormComponent', () => { }); beforeEach(() => { + settingSignal('visitors.duration_step').set(undefined); + settingSignal('visitors.min_duration').set(undefined); + settingSignal('visitors.max_duration').set(undefined); + settingSignal('visitors.custom_duration_options').set(undefined); + settingSignal('bookings.duration_step').set(15); + settingSignal('bookings.min_duration').set(30); + settingSignal('bookings.max_duration').set(180); + settingSignal('bookings.custom_duration_options').set([]); spectator = createComponent(); }); + it('should create component', () => + expect(spectator.component).toBeTruthy()); + + it('should allow a configured 45 minute visitor duration below the minimum', () => { + settingSignal('visitors.duration_step').set(30); + settingSignal('visitors.min_duration').set(60); + settingSignal('visitors.custom_duration_options').set([45]); + + expect(spectator.component.duration_step()).toBe(30); + expect(spectator.component.min_duration()).toBe(60); + expect(spectator.component.custom_duration_options()).toEqual([45]); + expect(spectator.component.effective_min_duration()).toBe(45); + }); + it('should filter visitor suggestions by the search term', () => { spectator.component.visitors.set([ { email: 'alice@example.com', name: 'Alice', company: 'Acme' }, diff --git a/libs/catering/src/lib/catering-list-field.component.ts b/libs/catering/src/lib/catering-list-field.component.ts index 4e5775651b..8af49146ad 100644 --- a/libs/catering/src/lib/catering-list-field.component.ts +++ b/libs/catering/src/lib/catering-list-field.component.ts @@ -36,235 +36,278 @@ const EMPTY_FAVS = []; @Component({ selector: `catering-list-field`, template: ` -
- @for (order of orders(); track order.id) { -
-
-
- - - @for (room of rooms(); track room.id) { - - } - -
-
+ @if (orders().length) { +
+ @for (order of orders(); track order.id) { +
+
+
+ + + @for (room of rooms(); track room.id) { + + } + +
+
+ {{ + 'CALENDAR_EVENT.CATERING_ORDER_AT_DATE' + | translate + : { + date: + order.deliver_at_time + | date + : 'mediumDate', + time: + order.deliver_at_time + | date + : time_format(), + } + }} +
+ @if (end_time() < order.deliver_at) { +
+ priority_high +
+ } +
+
{{ - 'CALENDAR_EVENT.CATERING_ORDER_AT_DATE' + 'CALENDAR_EVENT.CATERING_ORDER_DETAILS' | translate : { - date: - order.deliver_at - | date: 'mediumDate', - time: - order.deliver_at - | date: time_format(), + count: order.item_count, + cost: + order.total_cost / 100 + | currency + : currency_code(), } }}
- @if (end_time() < order.deliver_at) { -
- priority_high -
- } -
-
- {{ - 'CALENDAR_EVENT.CATERING_ORDER_DETAILS' - | translate - : { - count: order.item_count, - cost: - order.total_cost / 100 - | currency - : currency_code(), - } - }}
-
- @if (!disabled()) { - - } - @if (!disabled()) { + @if (!disabled()) { + + } + @if (!disabled()) { + + } + @if (!disabled()) { + + } - } - @if (!disabled()) { - - } - -
-
- @for (item of order.items; track item.custom_id) { -
-
- {{ item.name || 'Item' }} - @if (item.option_list?.length) { - - {{ - 'CALENDAR_EVENT.CATERING_ORDER_OPTION_COUNT' - | translate - : { - count: - item - .option_list - ?.length || - '0', - } - }} - - } -
-
- x{{ item.quantity }} -
+ @for (item of order.items; track item.custom_id) {
- {{ - item.unit_price_with_options / 100 - | currency: currency_code() - }} - ea -
- @if (!disabled()) { +
+ {{ item.name || 'Item' }} + @if (item.option_list?.length) { + + {{ + 'CALENDAR_EVENT.CATERING_ORDER_OPTION_COUNT' + | translate + : { + count: + item + .option_list + ?.length || + '0', + } + }} + + } +
+
+ x{{ item.quantity }} +
+
+ {{ + item.unit_price_with_options / 100 + | currency: currency_code() + }} + ea +
+ @if (!disabled()) { + + } - } - -
- } + favorite + +
+ } +
+ } +
+ + } @else { + @if (disabled()) { +
+ hand_meal +

+ Catering is not available for the selected space and/or + time +

+
+ } @else { +
+

No catering orders for this booking

+
} -
- + } @for (room of rooms(); track room.id) { -
+
@if (level()) { - -
- {{ level()?.display_name || level()?.name }} + + +
+ {{ level().display_name || level().name }} +
}
@@ -99,7 +104,7 @@ export class MapLocateModalComponent extends AsyncHandler implements OnInit { /** Space to show the location of on the map */ public readonly item = signal(this._data.item); /** Features of the map */ - public features = signal(undefined); + public readonly features = signal([]); /** Mapping of elements to CSS styles */ public style_map = signal({}); diff --git a/libs/components/src/lib/settings-toggle.component.ts b/libs/components/src/lib/settings-toggle.component.ts index ad6dbe9e1e..2b11e6801c 100644 --- a/libs/components/src/lib/settings-toggle.component.ts +++ b/libs/components/src/lib/settings-toggle.component.ts @@ -19,8 +19,14 @@ import { IconComponent } from './icon.component'; [class.border-info]="value()" (click)="setValue(!value())" > -
-
+
+
{{ label() }}
@if (info() && inline()) {
{{ info() }}
diff --git a/libs/components/src/tests/global-loading.component.spec.ts b/libs/components/src/tests/global-loading.component.spec.ts index 537e376501..01076014ab 100644 --- a/libs/components/src/tests/global-loading.component.spec.ts +++ b/libs/components/src/tests/global-loading.component.spec.ts @@ -75,6 +75,52 @@ describe('GlobalLoadingComponent', () => { expect('[loader]').not.toExist(); }); + describe('loading gradient', () => { + const background = () => + spectator.query('[loader]').style.backgroundImage; + + afterEach(() => localStorage.removeItem('PLACEOS.loading_gradient')); + + it('should use the colours from the domain config', () => { + spectator.detectChanges(); + expect(background()).toBe(''); + + vi.mocked(authority).mockReturnValue({ + id: 'test', + config: { loading_gradient: ['#f15b55', '#f68c50'] }, + } as any); + vi.advanceTimersByTime(1000); + spectator.detectChanges(); + + expect(background()).toContain('linear-gradient'); + expect(localStorage.getItem('PLACEOS.loading_gradient')).toBe( + 'linear-gradient(to right, #f15b55, #f68c50)', + ); + }); + + it('should keep the default gradient for values that are not colours', () => { + vi.mocked(authority).mockReturnValue({ + id: 'test', + config: { loading_gradient: ['#f15b55', 'url(//example.com)'] }, + } as any); + vi.advanceTimersByTime(1000); + spectator.detectChanges(); + + expect(background()).toBe(''); + }); + + it('should show the last known gradient before the domain loads', () => { + localStorage.setItem( + 'PLACEOS.loading_gradient', + 'linear-gradient(to right, red, blue)', + ); + spectator = createComponent(); + spectator.detectChanges(); + + expect(background()).toContain('linear-gradient'); + }); + }); + it('should show a server down message while offline', () => { vi.mocked(authority).mockReturnValue({ id: 'test' } as any); vi.mocked(token).mockReturnValue('test-token'); diff --git a/libs/events/src/lib/event-card.component.ts b/libs/events/src/lib/event-card.component.ts index a613082043..a339bdbc57 100644 --- a/libs/events/src/lib/event-card.component.ts +++ b/libs/events/src/lib/event-card.component.ts @@ -14,6 +14,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, RouterModule } from '@angular/router'; import { AsyncHandler, + BOOKING_TYPE_COLORS, CalendarEvent, formatRecurrence, fromEventRecurrence, @@ -36,7 +37,7 @@ import { GroupEventDetailsModalComponent } from './group-event-details-modal.com selector: 'event-card', template: ` @if (event()) { -

+

@if (show_day()) { {{ day() }},  } @@ -57,6 +58,18 @@ import { GroupEventDetailsModalComponent } from './group-event-details-modal.com
+
+ + {{ typeIcon }} + +

{{ event()?.title }}

@@ -130,9 +143,12 @@ import { GroupEventDetailsModalComponent } from './group-event-details-modal.com > chevron_right - @if (event()?.attendees?.length) { + @if ( + event()?.attendees?.length && + !event()?.extension_data?.shared_event + ) {
@for ( user of event()?.attendees @@ -288,6 +304,25 @@ export class EventCardComponent extends AsyncHandler implements OnInit { } }); + public get typeIcon() { + return this.event()?.extension_data?.shared_event + ? 'event_available' + : 'meeting_room'; + } + + public get typeLabel() { + return this.event()?.extension_data?.shared_event + ? 'RESOURCE.EVENT' + : 'RESOURCE.ROOM'; + } + + public get typeColors() { + const type = this.event()?.extension_data?.shared_event + ? 'group-event' + : 'event'; + return BOOKING_TYPE_COLORS[type]; + } + constructor() { super(); } diff --git a/libs/events/src/lib/event-details-modal.component.ts b/libs/events/src/lib/event-details-modal.component.ts index eced600586..91490a086b 100644 --- a/libs/events/src/lib/event-details-modal.component.ts +++ b/libs/events/src/lib/event-details-modal.component.ts @@ -782,7 +782,7 @@ export class EventDetailsModalComponent implements OnInit { public readonly raw_body = signal(''); public readonly print = signal(false); public readonly show_attendees = signal(false); - public readonly event = signal(this._data.event); + public readonly event = signal(new CalendarEvent(this._data.event)); public readonly no_edit_message = signal( 'Editing bookings long than \n a day is not available', ); @@ -877,30 +877,33 @@ export class EventDetailsModalComponent implements OnInit { ); }); - public readonly accept_count = computed(() => - this.event().attendees.reduce( - (count, user) => - (count += user.response_status === 'accepted' ? 1 : 0), - 0, - ), + public readonly accept_count = computed( + () => + this.event().attendees?.reduce( + (count, user) => + (count += user.response_status === 'accepted' ? 1 : 0), + 0, + ) || 0, ); - public readonly declined_count = computed(() => - this.event().attendees.reduce( - (count, user) => - (count += user.response_status === 'declined' ? 1 : 0), - 0, - ), + public readonly declined_count = computed( + () => + this.event().attendees?.reduce( + (count, user) => + (count += user.response_status === 'declined' ? 1 : 0), + 0, + ) || 0, ); - public readonly pending_count = computed(() => - this.event().attendees.reduce( - (count, user) => - (count += - user.response_status === 'tentative' || - user.response_status === 'needsAction' - ? 1 - : 0), - 0, - ), + public readonly pending_count = computed( + () => + this.event().attendees?.reduce( + (count, user) => + (count += + user.response_status === 'tentative' || + user.response_status === 'needsAction' + ? 1 + : 0), + 0, + ) || 0, ); public readonly host_name = computed(() => { diff --git a/libs/events/src/lib/event-form.service.ts b/libs/events/src/lib/event-form.service.ts index 2df12a958a..8e45bda9fd 100644 --- a/libs/events/src/lib/event-form.service.ts +++ b/libs/events/src/lib/event-form.service.ts @@ -31,6 +31,7 @@ import { onFieldChange, sameDayInTimezone, setDefaultCreator, + SETTING_KEYS, SettingsService, Space, unique, @@ -107,7 +108,10 @@ export interface EventFormOptions { } export interface EventFormFilters { + /** Minimum room capacity. Values below 1 mean no limit */ capacity?: number; + /** Maximum room capacity. Empty means no upper limit */ + capacity_max?: number; /** Whether to only show favourite rooms */ show_fav?: boolean; features?: string[]; @@ -356,8 +360,12 @@ export class EventFormService extends AsyncHandler { list = list.filter(({ id }) => this.favorite_spaces.includes(id)); } if (filters.capacity > 0) { + const max = + filters.capacity_max > 0 ? filters.capacity_max : Infinity; list = list.filter( - ({ capacity }) => filters.capacity <= capacity || capacity < 0, + ({ capacity }) => + capacity < 0 || + (capacity >= filters.capacity && capacity <= max), ); } if (filters.features) { @@ -365,7 +373,13 @@ export class EventFormService extends AsyncHandler { filters.features.every((f) => features.includes(f)), ); } - return list; + return list.sort((a, b) => { + const cap_diff = (a.capacity || 0) - (b.capacity || 0); + if (cap_diff !== 0) return cap_diff; + return (a.display_name || a.name).localeCompare( + b.display_name || b.name, + ); + }); }); /** Params driving the available space calculation */ @@ -437,7 +451,7 @@ export class EventFormService extends AsyncHandler { } public get favorite_spaces() { - return this._settings.get('favourite_spaces') || []; + return this._settings.get(SETTING_KEYS.FAVORITE_ROOMS) || []; } public get book_internal() { diff --git a/libs/events/src/lib/space-select-modal/space-location-pin.component.ts b/libs/events/src/lib/space-select-modal/space-location-pin.component.ts index a28c6c47c2..7606ca8255 100644 --- a/libs/events/src/lib/space-select-modal/space-location-pin.component.ts +++ b/libs/events/src/lib/space-select-modal/space-location-pin.component.ts @@ -1,11 +1,13 @@ -import { Component, computed, inject } from '@angular/core'; +import { Component, computed, HostListener, inject } from '@angular/core'; import { MAP_FEATURE_DATA } from 'libs/common/src/lib/types'; import { IconComponent } from 'libs/components/src/lib/icon.component'; @Component({ selector: 'space-location-pin', template: ` -
+
( - 'favourite_spaces', + SETTING_KEYS.FAVORITE_ROOMS, [], true, ); @@ -370,12 +369,13 @@ export class SpaceSelectModalComponent { this.show_filters.set(false); } - public toggleFavourite(item: Space) { + public toggleFavourite(item: Space | null) { + if (!item?.id) return; const fav_list = this.favorites(); const next_favs = fav_list.includes(item.id) ? fav_list.filter((_) => _ !== item.id) : [...fav_list, item.id]; this.favorites.set(next_favs); - this._settings.saveUserSetting('favourite_spaces', next_favs); + this._settings.saveUserSetting(SETTING_KEYS.FAVORITE_ROOMS, next_favs); } } diff --git a/libs/events/src/lib/spaces.service.ts b/libs/events/src/lib/spaces.service.ts index 468fdb2ee7..94bf0a73aa 100644 --- a/libs/events/src/lib/spaces.service.ts +++ b/libs/events/src/lib/spaces.service.ts @@ -43,7 +43,9 @@ export class SpacesService { ); /** List of available features */ public readonly features = computed(() => - unique(flatten(this.list().map((i) => i.features))), + unique( + flatten(this.list().map((i) => i.features.filter((_) => _.trim()))), + ), ); /** Default predicate for filter method */ protected _compare = (space: Space) => @@ -99,7 +101,7 @@ export class SpacesService { private async loadSpaces(): Promise { const systems = ( await querySystems({ - zone_id: this._org.organisation.id, + zone_id: this._org.organisation?.id, limit: 5000, }) ).data; diff --git a/libs/events/src/tests/event-form.service.spec.ts b/libs/events/src/tests/event-form.service.spec.ts index 22811c0b00..62bddcb4a8 100644 --- a/libs/events/src/tests/event-form.service.spec.ts +++ b/libs/events/src/tests/event-form.service.spec.ts @@ -555,6 +555,33 @@ describe('EventFormService', () => { expect(transform_spy).not.toHaveBeenCalled(); }); + describe('room capacity filter', () => { + const capacities = () => + service.filtered_spaces().map((space) => space.capacity); + + beforeEach(() => { + const spaces = [2, 4, 12].map( + (capacity) => + new Space({ + id: `space-${capacity}`, + zones: ['bld-1'], + capacity, + }), + ); + Object.assign(service, { spaces: signal(spaces) }); + }); + + it('should use the capacity as a minimum', () => { + service.setFilters({ capacity: 3 }); + expect(capacities()).toEqual([4, 12]); + }); + + it('should apply the upper limit only when one is set', () => { + service.setFilters({ capacity: 3, capacity_max: 4 }); + expect(capacities()).toEqual([4]); + }); + }); + it('should not make metadata requests before event data is consumed', () => { expect(ts_client.showMetadata).not.toHaveBeenCalled(); }); diff --git a/libs/events/src/tests/event.class.spec.ts b/libs/events/src/tests/event.class.spec.ts index 4dbc07bd85..c957ed7df9 100644 --- a/libs/events/src/tests/event.class.spec.ts +++ b/libs/events/src/tests/event.class.spec.ts @@ -235,6 +235,29 @@ describe('CalendarEvent', () => { expect(json.recurrence.days_of_week).toEqual(['wednesday']); }); + it('should preserve monthly nth-weekday recurrence metadata', () => { + const recurrence_start = new Date(2026, 4, 13, 9).valueOf(); + event = new CalendarEvent({ + date: new Date(2026, 4, 12, 9).valueOf(), + date_end: new Date(2026, 4, 12, 10).valueOf(), + recurring: true, + recurrence: { + start: recurrence_start, + end: new Date(2026, 10, 30).valueOf(), + interval: 1, + pattern: 'monthly', + days_of_week: [3], + nth_of_month: WeekOfMonth.Second, + }, + }); + + const json = event.toJSON(); + + expect(json.recurrence.pattern).toBe('monthly'); + expect(json.recurrence.days_of_week).toEqual(['wednesday']); + expect(json.recurrence.nth_of_month).toBe(WeekOfMonth.Second); + }); + it('should serialise recurring events until the selected end date', () => { const recurrence_end = new Date(2026, 5, 30, 23, 59, 59, 999).valueOf(); event = new CalendarEvent({ diff --git a/libs/events/src/tests/space-select-modal/space-map.component.spec.ts b/libs/events/src/tests/space-select-modal/space-map.component.spec.ts index ea08af2ac9..540be11849 100644 --- a/libs/events/src/tests/space-select-modal/space-map.component.spec.ts +++ b/libs/events/src/tests/space-select-modal/space-map.component.spec.ts @@ -118,6 +118,20 @@ describe('SpaceMapComponent', () => { expect(styles['#area-busy']).toEqual({ fill: DEFAULT_COLOURS.busy }); }); + it('should select a space from its map pin', async () => { + const space = new Space({ id: 'space-1', map_id: 'area-1' }); + const on_select = vi.fn(); + spectator.component.onSelect.subscribe(on_select); + available_spaces.set([space]); + await vi.waitFor(() => + expect(spectator.component.features()).toHaveLength(1), + ); + + spectator.component.features()[0].data.onSelect(); + + expect(on_select).toHaveBeenCalledWith(space); + }); + it('should update options and coordinates when setting a level', () => { spectator.component.setLevel(level_1); expect( diff --git a/libs/events/src/tests/space-select-modal/space-select-modal.component.spec.ts b/libs/events/src/tests/space-select-modal/space-select-modal.component.spec.ts index 501eafe3ad..92f1d3e23c 100644 --- a/libs/events/src/tests/space-select-modal/space-select-modal.component.spec.ts +++ b/libs/events/src/tests/space-select-modal/space-select-modal.component.spec.ts @@ -5,7 +5,12 @@ import { MatDialogRef, } from '@angular/material/dialog'; import { createRoutingFactory, Spectator } from '@ngneat/spectator/vitest'; -import { OrganisationService, SettingsService, Space } from '@placeos/common'; +import { + OrganisationService, + SETTING_KEYS, + SettingsService, + Space, +} from '@placeos/common'; import { MockComponent, MockModule, MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; import { generateEventForm } from '../../lib/event-form'; @@ -215,7 +220,7 @@ describe('SpaceSelectModalComponent', () => { expect(spectator.component.favorites()).toEqual(['1']); expect( spectator.inject(SettingsService).saveUserSetting, - ).toHaveBeenCalledWith('favourite_spaces', ['1']); + ).toHaveBeenCalledWith(SETTING_KEYS.FAVORITE_ROOMS, ['1']); }); it('should allow un-favouriting a space', () => { @@ -223,7 +228,7 @@ describe('SpaceSelectModalComponent', () => { spectator.component.toggleFavourite(new Space({ id: '1' })); expect( spectator.inject(SettingsService).saveUserSetting, - ).toHaveBeenCalledWith('favourite_spaces', ['1']); + ).toHaveBeenCalledWith(SETTING_KEYS.FAVORITE_ROOMS, ['1']); }); }); @@ -234,7 +239,7 @@ describe('SpaceSelectModalComponent (with favourites)', () => { providers: [ MockProvider(SettingsService, { get: vi.fn((key: string) => - key === 'favourite_spaces' ? ['1'] : undefined, + key === SETTING_KEYS.FAVORITE_ROOMS ? ['1'] : undefined, ) as any, overrides: signal([]), saveUserSetting: vi.fn(), @@ -295,6 +300,6 @@ describe('SpaceSelectModalComponent (with favourites)', () => { expect(spectator.component.favorites()).toEqual([]); expect( spectator.inject(SettingsService).saveUserSetting, - ).toHaveBeenCalledWith('favourite_spaces', []); + ).toHaveBeenCalledWith(SETTING_KEYS.FAVORITE_ROOMS, []); }); }); diff --git a/libs/events/tsconfig.spec.json b/libs/events/tsconfig.spec.json index 94be5ce7ec..957611b10c 100644 --- a/libs/events/tsconfig.spec.json +++ b/libs/events/tsconfig.spec.json @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "isolatedModules": true }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "src/index.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/libs/explore/src/lib/explore-booking-modal.component.ts b/libs/explore/src/lib/explore-booking-modal.component.ts index 6d1eacdef7..d89d301416 100644 --- a/libs/explore/src/lib/explore-booking-modal.component.ts +++ b/libs/explore/src/lib/explore-booking-modal.component.ts @@ -201,7 +201,10 @@ export class ExploreBookingModalComponent implements OnInit { inject>(MatDialogRef); private _router = inject(Router); - public readonly loading = this._event_form.loading; + private _min_loading = signal(true); + public readonly loading = computed( + () => this._min_loading() || !!this._event_form.loading(), + ); public readonly alert = signal(this._data.alert); public get form() { @@ -236,6 +239,14 @@ export class ExploreBookingModalComponent implements OnInit { ); public ngOnInit() { + if (!this._data.space) { + notifyError( + 'Error intialising Ad-hoc booking form. [Space missing]', + ); + console.error('Book Modal Data:', this._data); + this._dialog_ref.close(); + return; + } this._event_form.newForm(); this.model.update((m) => ({ ...m, @@ -243,6 +254,7 @@ export class ExploreBookingModalComponent implements OnInit { host: currentUser().email, organiser: currentUser(), })); + setTimeout(() => this._min_loading.set(false), 500); } public async save() { diff --git a/libs/explore/src/lib/explore-desk-info.component.ts b/libs/explore/src/lib/explore-desk-info.component.ts index 487115130f..ba15522fe7 100644 --- a/libs/explore/src/lib/explore-desk-info.component.ts +++ b/libs/explore/src/lib/explore-desk-info.component.ts @@ -3,7 +3,7 @@ import { Component, ElementRef, OnInit, - WritableSignal, + Signal, computed, inject, signal, @@ -28,14 +28,14 @@ type DeskStatus = export interface DeskInfoData { id: string; map_id: string; - user: WritableSignal; + user: Signal; name: string; start?: number; end?: number; department?: string; date?: number; - status: WritableSignal; - bookings: WritableSignal; + status: Signal; + bookings: Signal; } @Component({ diff --git a/libs/explore/src/lib/explore-space-info.component.ts b/libs/explore/src/lib/explore-space-info.component.ts index 8090398b08..9d691a9445 100644 --- a/libs/explore/src/lib/explore-space-info.component.ts +++ b/libs/explore/src/lib/explore-space-info.component.ts @@ -47,15 +47,15 @@ export interface SpaceInfoData { [xPosition]="'center'" [yPosition]="'center'" [hover]="true" - [attr.id]="space()?.map_id || space()?.id" + [attr.id]="space().map_id || space().id" (mouseenter)="updateOffset()" class="pointer-events-auto relative h-full w-full cursor-pointer" >
@@ -67,7 +67,7 @@ export interface SpaceInfoData { [class.h-32]="space().images[0]" [class.h-8]="!space().images[0]" > - @if (space().images[0]) { + @if (space().images?.length) { ('top'); public readonly x_pos = signal<'start' | 'end'>('start'); /** Space to display details for */ - public readonly space = signal(this._details.space); + public readonly space = signal(this._details.space || new Space()); /** List of upcoming events for space */ public readonly events = signal(this._details.events || []); /** List of upcoming events for space */ diff --git a/libs/explore/src/tests/explore-booking-modal.component.spec.ts b/libs/explore/src/tests/explore-booking-modal.component.spec.ts index 4f611d87e6..63a111dcd3 100644 --- a/libs/explore/src/tests/explore-booking-modal.component.spec.ts +++ b/libs/explore/src/tests/explore-booking-modal.component.spec.ts @@ -52,7 +52,12 @@ describe('ExploreBookingModalComponent', () => { ], }); - beforeEach(() => (spectator = createComponent())); + beforeEach(() => { + spectator = createComponent(); + // Bypass the 500ms _min_loading timer so the form renders + (spectator.component as any)._min_loading.set(false); + spectator.detectChanges(); + }); it('should allow changing the title and duration', () => { spectator.detectChanges(); diff --git a/libs/form-fields/src/index.ts b/libs/form-fields/src/index.ts index 37f0c99f83..ccce7aa7b0 100644 --- a/libs/form-fields/src/index.ts +++ b/libs/form-fields/src/index.ts @@ -3,6 +3,7 @@ export * from './lib/counter.component'; export * from './lib/date-calendar.component'; export * from './lib/date-field.component'; export * from './lib/date-options.component'; +export * from './lib/date-range-calendar.component'; export * from './lib/date-range-field.component'; export * from './lib/duration-field.component'; export * from './lib/host-select-field.component'; diff --git a/libs/form-fields/src/lib/date-field.component.ts b/libs/form-fields/src/lib/date-field.component.ts index 4728a41ba9..7ee60f878e 100644 --- a/libs/form-fields/src/lib/date-field.component.ts +++ b/libs/form-fields/src/lib/date-field.component.ts @@ -244,7 +244,7 @@ export class DateFieldComponent * @param new_value New value to set on the form field */ public setValue(new_value: number) { - // Keep the existing wall-clock time in the selected timezone. + // Preserve the selected calendar day and existing wall-clock time in the selected timezone. const timezone = this.timezone() || undefined; const { hours, minutes } = getTimeInTimezone( this.date() || Date.now(), @@ -269,7 +269,7 @@ export class DateFieldComponent ), ).valueOf(); } - // Check that new date is before from + // Check that new date is not before the configured minimum. if (new_date < this.from().valueOf()) { new_date = this.from().valueOf(); } diff --git a/libs/form-fields/src/lib/date-range-calendar.component.ts b/libs/form-fields/src/lib/date-range-calendar.component.ts index 3f245c3441..a689080e5e 100644 --- a/libs/form-fields/src/lib/date-range-calendar.component.ts +++ b/libs/form-fields/src/lib/date-range-calendar.component.ts @@ -41,7 +41,10 @@ import { IconComponent } from 'libs/components/src/lib/icon.component';
-
+
@@ -111,6 +114,8 @@ export class DateRangeCalendarComponent implements OnInit, OnChanges { public readonly to_date = input(undefined, { alias: 'to' }); /** Index of the day to start the week on when displaying the calendar */ public readonly offset_weekday = input(0); + /** Maximum number of days allowed in the selected range. `0` means no limit */ + public readonly max_range_days = input(0); /** Start date of the selected range */ public readonly startInput = input(undefined, { alias: 'start' }); public readonly start = linkedSignal(this.startInput); @@ -124,20 +129,18 @@ export class DateRangeCalendarComponent implements OnInit, OnChanges { /** Emitter for when the end date changes */ public readonly endChange = output(); + /** Whether user is actively selecting end date (after clicking start) */ + private _selecting_end = false; + /** Hovered date for preview during end selection */ public readonly hovered_date = signal(null); - /** - * True after the first click of a selection. The given start and end - * only show the current range, so the first click always starts a new - * range and the second click sets its end. - */ - private readonly _picking_end = signal(false); public readonly weekdays = signal([]); public readonly month_days = signal([]); public readonly end_after_start = computed(() => { - const end = this.end(); - return end && end > this.start() && !isSameDay(end, this.start()); + const start = this.start(); + const end = this._selecting_end ? this.hovered_date() : this.end(); + return !!start && !!end && end > start && !isSameDay(end, start); }); public ngOnInit() { @@ -157,27 +160,59 @@ export class DateRangeCalendarComponent implements OnInit, OnChanges { if (changes.month) { this._setMonthDays(); } - if (changes.end) this._setMonthDays(); + if (changes.end) { + if (!this._selecting_end) { + this._setMonthDays(); + } + } } public selectDate(date: number) { const start = this.start(); - if (!this._picking_end() || !start || date < start) { + const max_days = this.max_range_days(); + + if (this._selecting_end) { + if (date >= start) { + let end_date = date; + if (max_days > 0) { + const max_end_date = addDays(start, max_days - 1).valueOf(); + end_date = Math.min(date, max_end_date); + } + this.end.set(end_date); + this.endChange.emit(end_date); + } else { + this.start.set(date); + this.startChange.emit(date); + this.end.set(null); + } + this._selecting_end = false; + this.hovered_date.set(null); + } else { this.start.set(date); this.startChange.emit(date); - this._picking_end.set(true); - } else { - this.end.set(date); - this.endChange.emit(date); - this._picking_end.set(false); + this.end.set(null); + this._selecting_end = true; } this._setMonthDays(); } public setHoveredDate(date: number) { - if (!this._picking_end() || !this.start()) return; - this.hovered_date.set(date); - this.end.set(date); + if (!this._selecting_end) return; + const start = this.start(); + const max_days = this.max_range_days(); + + if (start && max_days > 0) { + const max_end_date = addDays(start, max_days - 1).valueOf(); + this.hovered_date.set(Math.min(date, max_end_date)); + } else { + this.hovered_date.set(date); + } + this._setMonthDays(); + } + + public clearHoveredDate() { + if (!this._selecting_end) return; + this.hovered_date.set(null); this._setMonthDays(); } @@ -196,28 +231,56 @@ export class DateRangeCalendarComponent implements OnInit, OnChanges { } private _setMonthDays() { - const start = startOfWeek(startOfMonth(this.month()), { + const week_start = startOfWeek(startOfMonth(this.month()), { weekStartsOn: this.offset_weekday() as any, }); + const range_start = this.start(); + const range_end = this._selecting_end + ? this.hovered_date() + : this.end(); + const from_date = this.from_date(); + const to_date = this.to_date(); + const max_days = this.max_range_days(); + + const max_end_date = + this._selecting_end && range_start && max_days > 0 + ? addDays(range_start, max_days - 1).valueOf() + : null; + this.month_days.set( Array.from(Array(7 * 6).keys()).map((i) => { - const date = addDays(start, i).valueOf(); - const end = this.end(); - const from_date = this.from_date(); - const to_date = this.to_date(); + const date = addDays(week_start, i).valueOf(); + const is_start = range_start && isSameDay(date, range_start); + + const exceeds_max_range = + this._selecting_end && + range_start && + max_end_date && + date > max_end_date; + + const is_end = + range_end && + range_start && + range_end >= range_start && + isSameDay(date, range_end); + const is_selected = + range_end && + range_start && + range_end >= range_start && + date >= startOfDay(range_start).valueOf() && + date <= endOfDay(range_end).valueOf(); + return { id: date, disabled: (from_date && isBefore(date, from_date)) || - (to_date && isAfter(date, to_date)), + (to_date && isAfter(date, to_date)) || + exceeds_max_range, is_today: isSameDay(date, Date.now()), - is_start: isSameDay(date, this.start()), - is_end: isSameDay(date, this.end()), + is_start, + is_end, is_month: isSameMonth(date, this.month()), - is_selected: - end && - date >= startOfDay(this.start()).valueOf() && - date <= endOfDay(end).valueOf(), + is_selected, }; }), ); diff --git a/libs/form-fields/src/lib/user-list-field.component.ts b/libs/form-fields/src/lib/user-list-field.component.ts index 2f0b936654..5ac8387d24 100644 --- a/libs/form-fields/src/lib/user-list-field.component.ts +++ b/libs/form-fields/src/lib/user-list-field.component.ts @@ -228,6 +228,7 @@ const DENIED_FILE_TYPES = [
+
}
diff --git a/libs/form-fields/src/tests/date-field.component.spec.ts b/libs/form-fields/src/tests/date-field.component.spec.ts index 6f4c9fad8c..e803d7f0b4 100644 --- a/libs/form-fields/src/tests/date-field.component.spec.ts +++ b/libs/form-fields/src/tests/date-field.component.spec.ts @@ -1,7 +1,7 @@ import { FormsModule, NgControl } from '@angular/forms'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { addDays, format, set, startOfMinute } from 'date-fns'; -import { fromZonedTime, toZonedTime } from 'date-fns-tz'; +import { addDays, format } from 'date-fns'; +import { toZonedTime } from 'date-fns-tz'; import { MockComponent, MockProvider } from 'ng-mocks'; import { MatDatepickerModule } from '@angular/material/datepicker'; @@ -73,6 +73,22 @@ describe('DateFieldComponent', () => { )[0]; }; + const expectPreservedDateAndTime = ( + result: number, + previous_date: number, + selected_date: number, + timezone: string, + ) => { + expect(getTimeInTimezone(result, timezone)).toEqual( + getTimeInTimezone(previous_date, timezone), + ); + const zoned_result = toZonedTime(result, timezone); + const expected_date = new Date(selected_date); + expect(zoned_result.getFullYear()).toEqual(expected_date.getFullYear()); + expect(zoned_result.getMonth()).toEqual(expected_date.getMonth()); + expect(zoned_result.getDate()).toEqual(expected_date.getDate()); + }; + it('should be able to be disabled', () => { expect('button').not.toBeDisabled(); spectator.component.setDisabledState(true); @@ -116,35 +132,64 @@ describe('DateFieldComponent', () => { expect(spectator.query('button[aria-label="Clear date"]')).toBeNull(); }); + it('should preserve wall-clock time in the configured timezone', () => { + const timezone = 'UTC'; + + const old_date = new Date('2030-04-08T15:30:00.000Z').valueOf(); + const new_date = new Date(2030, 3, 12).valueOf(); + const on_change = vi.fn(); + spectator.setInput('timezone', timezone); + spectator.component.registerOnChange(on_change); + spectator.component.writeValue(old_date); + + spectator.component.setValue(new_date); + + expectPreservedDateAndTime( + on_change.mock.calls[0][0], + old_date, + new_date, + timezone, + ); + }); + + it('should keep a past date that the minimum allows', () => { + const timezone = 'UTC'; + const old_date = new Date('2020-04-08T15:30:00.000Z').valueOf(); + const new_date = new Date(2020, 3, 12).valueOf(); + const on_change = vi.fn(); + spectator.setInput('from', 1); + spectator.setInput('timezone', timezone); + spectator.component.registerOnChange(on_change); + spectator.component.writeValue(old_date); + + spectator.component.setValue(new_date); + + expectPreservedDateAndTime( + on_change.mock.calls[0][0], + old_date, + new_date, + timezone, + ); + }); + it('should preserve the selected calendar day in the configured timezone', () => { const timezone = pickTimezoneBehindLocal(); expect(timezone).toBeTruthy(); - const old_date = new Date('2026-04-08T15:30:00.000Z').valueOf(); - const new_date = new Date('2026-04-12T00:00:00.000Z').valueOf(); + + const old_date = new Date('2030-04-08T15:30:00.000Z').valueOf(); + const new_date = new Date(2030, 3, 12).valueOf(); const on_change = vi.fn(); - spectator.setInput('from', 1); spectator.setInput('timezone', timezone); spectator.component.registerOnChange(on_change); spectator.component.writeValue(old_date); spectator.component.setValue(new_date); - const { hours, minutes } = getTimeInTimezone(old_date, timezone); - const selected_date = new Date(new_date); - const expected = startOfMinute( - fromZonedTime( - set(toZonedTime(old_date, timezone), { - year: selected_date.getFullYear(), - month: selected_date.getMonth(), - date: selected_date.getDate(), - hours, - minutes, - seconds: 0, - milliseconds: 0, - }), - timezone, - ), - ).valueOf(); - expect(on_change).toHaveBeenCalledWith(expected); + expectPreservedDateAndTime( + on_change.mock.calls[0][0], + old_date, + new_date, + timezone, + ); }); }); diff --git a/libs/form-fields/src/tests/date-range-calendar.component.spec.ts b/libs/form-fields/src/tests/date-range-calendar.component.spec.ts index e627ee49b0..bc1938dac5 100644 --- a/libs/form-fields/src/tests/date-range-calendar.component.spec.ts +++ b/libs/form-fields/src/tests/date-range-calendar.component.spec.ts @@ -84,12 +84,27 @@ describe('DateRangeCalendarComponent', () => { const hovered = addDays(month, 6).valueOf(); spectator.component.selectDate(start); spectator.component.setHoveredDate(hovered); - expect(spectator.component.end()).toBe(hovered); + expect(spectator.component.hovered_date()).toBe(hovered); + expect(spectator.component.end_after_start()).toBe(true); + }); + + it('should not limit the range length by default', () => { + const end = addDays(month, 90).valueOf(); + spectator.component.selectDate(month.valueOf()); + spectator.component.selectDate(end); + expect(spectator.component.end()).toBe(end); + }); + + it('should limit the range length to max_range_days', () => { + spectator.setInput('max_range_days', 31); + spectator.component.selectDate(month.valueOf()); + spectator.component.selectDate(addDays(month, 90).valueOf()); + expect(spectator.component.end()).toBe(addDays(month, 30).valueOf()); }); it('should ignore hovered dates before a start is chosen', () => { spectator.component.setHoveredDate(addDays(month, 6).valueOf()); - expect(spectator.component.end()).toBeUndefined(); + expect(spectator.component.hovered_date()).toBeNull(); }); it('should navigate between months', () => { diff --git a/libs/users/src/lib/find-availability-modal/find-availability-modal.component.ts b/libs/users/src/lib/find-availability-modal/find-availability-modal.component.ts index 7f6a1ce8eb..02dcc826d5 100644 --- a/libs/users/src/lib/find-availability-modal/find-availability-modal.component.ts +++ b/libs/users/src/lib/find-availability-modal/find-availability-modal.component.ts @@ -332,7 +332,7 @@ export class FindAvailabilityModalComponent fromUnixTime(block.starts_at), ); return { - date, + date: date.valueOf(), duration, start: ((date.getHours() + date.getMinutes() / 60) / diff --git a/shared/assets/icons/ben_icon.svg b/shared/assets/icons/ben_icon.svg new file mode 100644 index 0000000000..fd1bf90d22 --- /dev/null +++ b/shared/assets/icons/ben_icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/shared/assets/icons/roybot.png b/shared/assets/icons/roybot.png new file mode 100644 index 0000000000..e8fcd2c024 Binary files /dev/null and b/shared/assets/icons/roybot.png differ diff --git a/shared/assets/locale/ar.json b/shared/assets/locale/ar.json index 5b619b0cf0..39e01eb81e 100644 --- a/shared/assets/locale/ar.json +++ b/shared/assets/locale/ar.json @@ -559,6 +559,9 @@ "VISITOR_EMAIL": "البريد الإلكتروني للزائر", "VISITOR_EMAIL_PLACEHOLDER": "البريد الإلكتروني للزائر", "VISITOR_COMPANY": "شركة الزائر", + "VISITOR_COMPANY_PLACEHOLDER": "شركة الزائر", + "VISITOR_PHONE": "رقم الهاتف", + "VISITOR_PHONE_PLACEHOLDER": "رقم هاتف الزائر", "VISITOR_REASON": "سبب الزيارة", "VISITOR_REASON_PLACEHOLDER": "مثال: اجتماع متابعة", "VISITOR_PASS_PLACEHOLDER": "مثال: V-1234", @@ -568,8 +571,12 @@ "VISITOR_SENT_SINGLE": "تم إرسال دعوة الزائر إلى {{ name }}", "VISITOR_SENT_MULTIPLE": "تم إرسال دعوة الزائر إلى {{ count }} أشخاص", "VISITOR_SENT_MSG": "تم إرسال الدعوة للحضور إلى {{ location }} من {{ date }} في {{ time }}", + "VISITOR_SENT_MSG_MULTIPLE": "تم إرسال الدعوات للحضور إلى {{ location }} من {{ date }} في {{ time }}", "VISITOR_SENDING": "جاري إرسال دعوات الزوار...", "VISITOR_BOOK_ANOTHER": "حجز زائر آخر", + "VISITOR_BOOK_ANOTHER_MULTIPLE": "حجز المزيد من الزوار", + "SPACE_REMOVE": "إزالة هذه المساحة", + "SPACE_ADD_TO": "إضافة هذه المساحة", "INDUCTED": "تم توجيهه", "PASS_NUMBER": "رقم التصريح", "ASSETS_ADD_HEADER": "إضافة أصول", @@ -1722,6 +1729,7 @@ "REPORTS_NO_SHOWS": "عدم الحضور", "REPORTS_NO_SHOWS_PERCENT": "نسبة عدم الحضور %", "REPORTS_TOTAL_ATTENDEES": "إجمالي الحاضرين في الغرفة", + "REPORTS_ACTIVE_BOOKINGS_HEADER": "الحجوزات النشطة", "REPORTS_DAILY_HEADER": "الاستخدام اليومي", "REPORTS_DAILY_EMPTY": "لا توجد حجوزات لنطاق التاريخ المحدد", "REPORTS_APPROVED": "الحجوزات الموافق عليها", diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 834abe03e3..371dfecb58 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -1003,6 +1003,7 @@ "VIEW_AS_LIST": "View as List", "PENDING": "Pending", "CAPACITY": "Capacity", + "CAPACITY_ANY": "Any Capacity", "CAPACITY_EMPTY": "No capacity set", "SEARCH": "Search for...", "SEARCH_EMPTY": "No matches found.", @@ -1174,6 +1175,24 @@ "SAVING": "Saving...", "ITEM_REMOVE": "Remove item", "BUILDINGS_ALL": "All Buildings", + "FLOOR": "Floor", + "BOOK_DESK": "Book Desk", + "CONFIRMING": "Confirming Booking", + "REGION": "Region", + "BUILDING": "Building", + "BEGINS_IN": "In {{ minutes }} minutes", + "ENDS_IN": "For {{ minutes }} minutes", + "ZONE": "Zone", + "ALL_COLLEAGUES": "All Colleagues", + "MY_TEAM": "My Team", + "LOCATION_WFH": "Work from Home", + "LOCATION_OFFICE": "In Office", + "LOCATION_ONSITE": "Onsite", + "LOCATION_LEAVE": "On Leave", + "LOCATION_SICK": "Off Sick", + "ALL_DEPARTMENTS": "All Departments", + "ALL_OFFICES": "All Offices", + "ALL_STATUSES": "All Statuses", "LOAD_ERROR": "Could not load the data. Check your connection, then try again.", "RETRY": "Retry", "SELECT_ALL": "Select all", @@ -1181,7 +1200,8 @@ "CLEAR_SELECTION": "Clear selection", "BULK_LOADING": "Updating items ({{ count }})...", "BULK_SUCCESS": "Items updated: {{ count }}", - "BULK_PARTIAL": "{{ failed }} of {{ total }} items did not update." + "BULK_PARTIAL": "{{ failed }} of {{ total }} items did not update.", + "DESK": "Desk" }, "LANGUAGE": { "ENGLISH": "English", @@ -1227,7 +1247,9 @@ "RULESET_REMOVE_RULES": "Remove Rule", "RULESET_NAME_REQUIRED": "A ruleset name is required", "RULESET_VALUE_REQUIRED": "A rule value is required", - "BUILDING_EMPTY": "No building" + "BUILDING_EMPTY": "No building", + "MEETING": "Meeting", + "MEETINGS": "Meetings" }, "FORM": { "INVALID_FIELDS": "Some fields are invalid. [{{ field_list }}]", @@ -1299,7 +1321,9 @@ "ASSETS_CLASH_ERROR": "Some of the items are not available for the selected date and time.", "ASSETS_TIME_ERROR": "Delivery time is outside of the event time.\nThis order will be ignored.", "ASSETS_QUANTITY_ERROR": "Not enough assets available to meet request [{{ name }}].\nAnother request for this asset has been made in this meeting.", - "ID_REQUIRED": "A valid ID is required" + "ID_REQUIRED": "A valid ID is required", + "BOOK_FOR": "Book for", + "TIME": "Time" }, "EXPLORE": { "AREAS": "Show Areas", @@ -1453,6 +1477,7 @@ "DESK_AVAILABLE_ERROR": "Desk is unavailable at this time.", "DESK_LIST_LOADING": "Finding available desks...", "DESK_LIST_EMPTY": "No available desks for selected time and/or filters", + "DESK_AUTO_ASSIGN_EMPTY": "There are no available desks via auto-assignment. Please try via the Book Desk option.", "DESK_SELECT_MSG": "Select a desk to view its details", "DESK_RESERVED_TITLE": "You have a reserved desk", "DESK_RESERVED_MESSAGE": "A desk has already been reserved for you, so you are unable to book another desk.", @@ -1518,6 +1543,12 @@ "PARKING_REQUEST_AFTER_HOURS_TITLE": "After-hours Private Access Request", "PARKING_REQUEST_AFTER_HOURS_DESC": "For access outside standard operating hours", "PARKING_REQUIRES_APPROVAL": "Requires Approval", + "P2_SPECIAL_NEEDS_DETAILS": "Special Needs Details", + "P2_REASON_FOR_REQUEST": "Reason for request", + "P2_REASON_PLACEHOLDER": "Please provide details about your special needs request...", + "P2_ATTACH_SUPPORTING_DOCS": "Attach supporting documents", + "P2_UPLOAD_FILE": "Upload File", + "P2_ACCEPTED_FORMATS": "Accepted formats: PDF, DOC, DOCX, JPG, PNG (max 10MB)", "PARKING_SHIFT_SELECTION": "Shift Selection", "PARKING_SHIFT_TYPE": "Shift Type", "PARKING_SHIFT_BUSINESS": "Business Hours", @@ -1537,6 +1568,12 @@ "PARKING_SPACE_RESTRICTION_REQUIRED": "Select a parking restriction other than None.", "PARKING_RESTRICTION_OVERSIZED": "Requires oversized space", "PARKING_SUMMARY_TITLE": "Summary + Submission", + "PARKING_ALLOCATION_INFO": "Allocation of parking spaces for the following week will occur the Friday afternoon prior. Requests received after allocation has occurred will automatically be waitlisted until a suitable space becomes available.", + "PARKING_ADVANCE_DAYS": "You can book up to {{ days }} days in advance.", + "PARKING_APPROVAL_GROUP": "Approval depends on your priority group.", + "PARKING_MANUAL_APPROVAL": "After hours requests require manual approval.", + "PARKING_AFTER_HOURS_WARNING": "Your After-hours request requires approval before confirmation.", + "PARKING_CANCEL": "Cancel", "VISITOR_INVITE_TITLE": "Invite Visitor", "VISITOR_EDIT_TITLE": "Edit Visitor", "VISITOR_NAME": "Visitor Name", @@ -1559,6 +1596,9 @@ "VISITOR_BOOK_ANOTHER_MULTIPLE": "Book more visitors", "INDUCTED": "Inducted", "PASS_NUMBER": "Pass Number", + "FACILITIES": "Features", + "EDIT_VISITOR_DETAILS": "Edit Visitor Details", + "RECURRING_TOOLTIP": "This booking is part of a recurring series", "ASSETS_ADD_HEADER": "Add Assets", "ASSETS_ADD": "Add this Asset", "ASSETS_SELECT": "Select an asset to view its details", @@ -1577,9 +1617,33 @@ "RECURRING_CLASHES_MSG": "Some instances in this recurring booking series clash with existing bookings.", "RECURRING_CLASHES_CONFIRM": "These instances will be skipped. Do you want to continue with the booking?", "CONTINUE_BOOKING": "Continue Booking", - "P2_SPECIAL_NEEDS_DETAILS": "Special Needs Details", - "P2_REASON_FOR_REQUEST": "Reason for request", - "P2_ATTACH_SUPPORTING_DOCS": "Supporting documents" + "DESK_GROUP": "Group", + "DESK_GROUP_MEMBERS_INFO": "Group members will be assigned desks nearby to your selected desk", + "DESK_DETAILS_HEADER": "Booking Details", + "DESK_SELECT_HEADER": "Select Desk", + "DESK_AUTO_ASSIGNED_TITLE": "Auto-Assigned Desk", + "DESK_REQUESTING": "Requesting desk...", + "DESK_FOUND": "Desk Found!", + "DESK_PERFECT_DESK_MESSAGE": "We've found the perfect desk for you", + "DESK_NEIGHBOURHOOD": "Neighbourhood", + "DESK_LOCATION_ON_MAP": "Location on Map", + "DESK_NO_MAP_AVAILABLE": "No map available", + "DESK_CONFIRM_BOOKING": "Confirm Booking", + "PARKING_SHIFT_DAY_WORKER": "Day Worker", + "PARKING_SHIFT_DAY_12HR": "Day Shift 12hr", + "PARKING_SHIFT_NIGHT_12HR": "Night Shift 12hr", + "PARKING_SHIFT_HALF_DAY_AM": "Half-Day AM", + "PARKING_SHIFT_HALF_DAY_PM": "Half-Day PM", + "VISITOR_LIST": "Visitors", + "VISITOR_PHONE": "Phone Number", + "VISITOR_PHONE_PLACEHOLDER": "Phone number of the visitor", + "VISITOR_PASS": "Pass Number", + "VISITOR_TIME_HEADER": "Visit Details", + "VISITOR_DETAILS_HEADER": "Visitor Details", + "VISITOR_SINGLE": "Single Visitor", + "VISITOR_MULTIPLE": "Multiple Visitors", + "VISITOR_RECENT": "Recently Booked Visitors", + "VISITOR_REQUIRED": "Add at least one visitor to the invite." }, "CALENDAR_EVENT": { "HOST_CHANGE_WARNING": "The previous host will remain an attendee after changing the host.", @@ -1681,7 +1745,31 @@ "CHECKING_AVAILABILITY": "Checking space availability...", "FIRST_INSTANCE_CLASH": "The first event in the series clashes with an existing booking", "RECURRING_CLASHES_NOT_ALLOWED": "Recurring events with {{ count }} clashing instance(s) are not allowed", - "CATERING_ORDER_TIME_ERROR": "Delivery time is outside of the event time.\nThis order will be ignored" + "CATERING_ORDER_TIME_ERROR": "Delivery time is outside of the event time.\nThis order will be ignored", + "MEETING_FLOW_HEADER": "Book a meeting room", + "MEETING_FLOW_STEP_SELECT_ROOM": "Select Room", + "MEETING_FLOW_STEP_CONFIRM": "Confirm & Options", + "MEETING_DETAILS_HEADER": "Meeting Details", + "MEETING_TITLE_LABEL": "Meeting Title", + "ROOM_SIZE_LABEL": "Room Size", + "ROOM_SIZE_1_2": "1–2 people", + "ROOM_SIZE_3_4": "3-4 people", + "ROOM_SIZE_5_8": "5-8 people", + "ROOM_SIZE_9_PLUS": "9+ people", + "SEARCH_ROOMS_BUTTON": "Search available rooms", + "SELECT_ROOM_HEADER": "Select Meeting Room", + "REVIEW_CONFIRM_HEADER": "Review & Confirm Booking", + "SELECTED_ROOM_HEADER": "Selected Room", + "CAPACITY_PEOPLE": "Capacity: {{ capacity }} people", + "FEATURES_MORE": "more", + "OPTIONAL_EXTRAS_HEADER": "Optional Extras", + "MEETING_SPACE_DEFAULT": "Meeting Space", + "ERROR_TITLE_REQUIRED_SELECT": "Please enter a meeting title before selecting a room", + "ERROR_TITLE_REQUIRED_SEARCH": "Please enter a meeting title before searching for rooms", + "ERROR_ROOM_REQUIRED": "Please select a meeting room before continuing", + "CATERING": "Catering", + "NOTES_HEADER": "Notes", + "CATERING_CHARGE_CODE": "Charge Code" }, "CATERING": { "MENU": "Catering Menu", @@ -1918,7 +2006,61 @@ "RESOURCE_CHECKED_IN": "Successfully checked in resource", "RESOURCE_CHECKED_IN_MESSAGE": "Resource checked in successfully", "TRY_AGAIN": "Try again", - "DEALS_EMPTY_TITLE": "No Deals or Offers Available" + "DEALS_EMPTY_TITLE": "No Deals or Offers Available", + "MENU_TEAM_SCHEDULE": "Team Schedule", + "UPCOMING_VIEW": "View All", + "UPCOMING_VIEW_BOOKINGS": "View All Bookings", + "COLLEAGUES_ADD": "Add Colleagues", + "COLLEAGUES_ADD_DESCRIPTION": "Search and select users to add them to your colleagues", + "COLLEAGUES_ADDING": "Saving new colleagues...", + "COLLEAGUES_ADD_CONFIRM": "Add {{ count }} colleagues", + "COLLEAGUES_AVAILABLE": "Available Users", + "COLLEAGUES_EXISTS": "User already added", + "COLLEAGUES_SELECTED": "{{ count }} colleagues", + "COLLEAGUES_BOOK_MEETING": "Book Meeting", + "COLLEAGUES_REMOVE": "Remove Colleague", + "COLLEAGUES_REMOVE_TITLE": "Remove Colleague", + "COLLEAGUES_REMOVE_MSG": "Are you sure you want to remove {{ name }} from your colleagues?", + "COLLEAGUES_REMOVE_LOADING": "Removing colleague...", + "COLLEAGUES_REMOVE_SUCCESS": "Colleague removed successfully", + "COLLEAGUES_BOOK_ROOM": "Book Room together", + "COLLEAGUES_BOOK_DESK": "Book Desk together", + "COLLEAGUES_ADD_FAVORITE": "Add to Favourites", + "COLLEAGUES_REMOVE_FAVORITE": "Remove from Favourites", + "COLLEAGUES_ADD_TEAM": "Add to My Team", + "COLLEAGUES_REMOVE_TEAM": "Remove from My Team", + "COLLEAGUE_SEARCH_EMPTY": "Start typing to search for users.", + "COLLEAGUE_SEARCH_NO_MATCH": "No matching users found for \"{{ text }}\" ", + "AVAILABLE_LIST_ROOMS": "Rooms", + "SCHEDULE_END_ALREADY_ENDED_ERROR": "Booking has already ended.", + "SCHEDULE_FILTERS": "Filters", + "DESK_WEEK_TITLE": "My Desk Bookings", + "DESK_WEEK_NEXT_MONDAY": "Next Monday", + "DESK_WEEK_PREVIOUS": "Previous week", + "DESK_WEEK_NEXT": "Next week", + "TEAM_SCHEDULE_BOOK_HINT": "Press on the card to book desk next to colleague", + "TEAM_SCHEDULE_EMPLOYEE": "Employee", + "TEAM_SCHEDULE_NO_FILTER_RESULTS": "No results found", + "TEAM_SCHEDULE_NO_FILTER_RESULTS_HINT": "Try adjusting your filters or search criteria.", + "TEAM_SCHEDULE_NO_MEMBERS": "No team members", + "TEAM_SCHEDULE_NO_MEMBERS_HINT": "Add team members or colleagues to your team to see their schedules.", + "VIRTUAL_CONCIERGE": "Virtual concierge", + "SCHEDULE_RANGE_HINT": "Pick a date range selecting the start then end date.", + "UPCOMING_ENDS_IN": "Ends in {{ minutes }} minutes", + "UPCOMING_STARTS_IN": "Starts in {{ minutes }} minutes", + "UPCOMING_NONE": "No Upcoming Bookings", + "UPCOMING_CLEAR_TITLE": "Your schedule is clear", + "UPCOMING_CLEAR_MSG": "Ready to plan your day? Book a space or find a colleague.", + "AVAILABLE_NOW_FREE": "{{ count }} free", + "AVAILABLE_NOW_VIEW_MAP": "View Live Map", + "AVAILABLE_NOW_DESKS_EMPTY": "No desks available at the moment", + "AVAILABLE_NOW_LOCKERS_EMPTY": "No lockers available at the moment", + "QUICK_ACTIONS_HEADER": "Quick Actions", + "QUICK_ACTIONS_AUTO_DESK": "Auto-assign Desk", + "QUICK_ACTIONS_AUTO_DESK_HINT": "We'll find you a desk for today", + "COLLEAGUES_ADD_SUCCESS": "{{ count }} colleagues added successfully", + "COLLEAGUES_ADD_SUCCESS_1": "Colleague added successfully", + "COLLEAGUES_ADD_ERROR": "Failed to add colleagues. Please try again." }, "CONCIERGE": { "MENU_BOOKINGS": "Bookings", @@ -2047,6 +2189,7 @@ "DESKS_BOOKING_DELETE_SERIES_CONTENT": "Are you sure you want to cancel the entire recurring desk booking series?", "DESKS_BOOKING_DELETE_SERIES_LOADING": "Cancelling desk booking series...", "DESKS_SECURITY": "Security Group", + "DESKS_HOMEBASE": "Homebase", "BOOKING_DELETED": "Cancelled", "BOOKING_ENDED": "Manually Ended", "BOOKING_EXPIRED": "Rejected", @@ -2410,6 +2553,8 @@ "PARKING_BOOKING_TYPE_BOOKED": "Allocated", "PARKING_BAY": "Parking Bay", "PARKING_BAY_NUMBER": "Bay Number", + "PARKING_USER_GROUPS": "Group at Request time", + "PARKING_ALLOCATION_GROUP": "Group at Allocation time", "PARKING_RESERVED_FOR": "Reserved For", "PARKING_RESERVED_BY": "Reserved By", "PARKING_CHECKED_OUT_AT": "Left at {{ time }}", @@ -2809,13 +2954,14 @@ "REPORTS_CANCELLED": "Cancelled", "REPORTS_TOTAL_BOOKINGS": "Total Bookings", "REPORTS_TOTAL_SITE_ATTENDANCE": "Total Site Attendance", - "REPORTS_TOTAL_RESERVATIONS": "Total Reservations", + "REPORTS_TOTAL_RESERVATIONS": "Requests", "REPORTS_TOTAL_TIME": "Total Booked Time", "REPORTS_TOTAL_VISITORS": "Total Visitors", "REPORTS_AVERAGE_LENGTH": "Average Length", "REPORTS_NO_SHOWS": "No shows", "REPORTS_NO_SHOWS_PERCENT": "% of No shows", "REPORTS_TOTAL_ATTENDEES": "Total Room Attendees", + "REPORTS_ACTIVE_BOOKINGS_HEADER": "Active Bookings", "REPORTS_DAILY_HEADER": "Daily Utilisation", "REPORTS_DAILY_EMPTY": "No bookings for the select date range", "REPORTS_APPROVED": "Approved Bookings", diff --git a/shared/assets/locale/en-GB.json b/shared/assets/locale/en-GB.json index 1168e7b3f9..56c21625c9 100644 --- a/shared/assets/locale/en-GB.json +++ b/shared/assets/locale/en-GB.json @@ -404,7 +404,7 @@ "SENSORS_QUEUE": "Queue Size: {{ value }}", "SENSORS_COUNT": "Count: {{ value }}", "SPACES_PERMISSIONS_ERROR": "You do not have permission to book this space at this time.", - "SPACES_UNAVAILABLE_ERROR": "{{ name }} is unavailable for the selected time and duration", + "SPACES_UNAVAILABLE_ERROR": "{{ name }} is unavailable at the current time", "PARKING_PERMISSIONS_ERROR": "Your user account has been denied parking access to {{ name }}", "PARKING_ASSIGNED_ERROR": "You are already assigned to parking space \"{{ name }}\"", "PARKING_EXISTING_ERROR": "You already have a parking space booked for the selected time.", @@ -568,6 +568,9 @@ "VISITOR_EMAIL": "Visitor Email", "VISITOR_EMAIL_PLACEHOLDER": "Email of the visitor", "VISITOR_COMPANY": "Visitor Company", + "VISITOR_COMPANY_PLACEHOLDER": "Company of the visitor", + "VISITOR_PHONE": "Phone Number", + "VISITOR_PHONE_PLACEHOLDER": "Phone number of the visitor", "VISITOR_REASON": "Reason for visit", "VISITOR_REASON_PLACEHOLDER": "e.g. Meeting Catchup", "VISITOR_PASS_PLACEHOLDER": "e.g. V-1234", @@ -577,8 +580,12 @@ "VISITOR_SENT_SINGLE": "Visitor invite sent to {{ name }}", "VISITOR_SENT_MULTIPLE": "Visitor invite sent to {{ count }} people", "VISITOR_SENT_MSG": "Invite has been sent to attend {{ location }} from {{ date }} at {{ time }}", + "VISITOR_SENT_MSG_MULTIPLE": "Invites have been sent to attend {{ location }} from {{ date }} at {{ time }}", "VISITOR_SENDING": "Sending out visitor invitations...", "VISITOR_BOOK_ANOTHER": "Book another visitor", + "VISITOR_BOOK_ANOTHER_MULTIPLE": "Book more visitors", + "SPACE_REMOVE": "Remove this space", + "SPACE_ADD_TO": "Add this space", "INDUCTED": "Inducted", "PASS_NUMBER": "Pass Number", "ASSETS_ADD_HEADER": "Add Assets", @@ -1793,13 +1800,14 @@ "REPORTS_CANCELLED": "Cancelled", "REPORTS_TOTAL_BOOKINGS": "Total Bookings", "REPORTS_TOTAL_SITE_ATTENDANCE": "Total Site Attendance", - "REPORTS_TOTAL_RESERVATIONS": "Total Reservations", + "REPORTS_TOTAL_RESERVATIONS": "Requests", "REPORTS_TOTAL_TIME": "Total Booked Time", "REPORTS_TOTAL_VISITORS": "Total Visitors", "REPORTS_AVERAGE_LENGTH": "Average Length", "REPORTS_NO_SHOWS": "No shows", "REPORTS_NO_SHOWS_PERCENT": "% of No shows", "REPORTS_TOTAL_ATTENDEES": "Total Room Attendees", + "REPORTS_ACTIVE_BOOKINGS_HEADER": "Active Bookings", "REPORTS_DAILY_HEADER": "Daily Utilisation", "REPORTS_DAILY_EMPTY": "No bookings for the select date range", "REPORTS_APPROVED": "Approved Bookings", diff --git a/shared/assets/locale/en-US.json b/shared/assets/locale/en-US.json index 8bb81ddd14..d2c091842e 100644 --- a/shared/assets/locale/en-US.json +++ b/shared/assets/locale/en-US.json @@ -568,6 +568,9 @@ "VISITOR_EMAIL": "Visitor Email", "VISITOR_EMAIL_PLACEHOLDER": "Email of the visitor", "VISITOR_COMPANY": "Visitor Company", + "VISITOR_COMPANY_PLACEHOLDER": "Company of the visitor", + "VISITOR_PHONE": "Phone Number", + "VISITOR_PHONE_PLACEHOLDER": "Phone number of the visitor", "VISITOR_REASON": "Reason for visit", "VISITOR_REASON_PLACEHOLDER": "e.g. Meeting Catchup", "VISITOR_PASS_PLACEHOLDER": "e.g. V-1234", @@ -577,8 +580,12 @@ "VISITOR_SENT_SINGLE": "Visitor invite sent to {{ name }}", "VISITOR_SENT_MULTIPLE": "Visitor invite sent to {{ count }} people", "VISITOR_SENT_MSG": "Invite has been sent to attend {{ location }} from {{ date }} at {{ time }}", + "VISITOR_SENT_MSG_MULTIPLE": "Invites have been sent to attend {{ location }} from {{ date }} at {{ time }}", "VISITOR_SENDING": "Sending out visitor invitations...", "VISITOR_BOOK_ANOTHER": "Book another visitor", + "VISITOR_BOOK_ANOTHER_MULTIPLE": "Book more visitors", + "SPACE_REMOVE": "Remove this space", + "SPACE_ADD_TO": "Add this space", "INDUCTED": "Inducted", "PASS_NUMBER": "Pass Number", "ASSETS_ADD_HEADER": "Add Assets", @@ -1396,6 +1403,8 @@ "PARKING_BOOKING_TYPE_BOOKED": "Allocated", "PARKING_BAY": "Parking Bay", "PARKING_BAY_NUMBER": "Bay Number", + "PARKING_USER_GROUPS": "Group at Request time", + "PARKING_ALLOCATION_GROUP": "Group at Allocation time", "PARKING_RESERVED_FOR": "Reserved For", "PARKING_RESERVED_BY": "Reserved By", "PARKING_CHECKED_OUT_AT": "Left at {{ time }}", @@ -1793,13 +1802,14 @@ "REPORTS_CANCELLED": "Cancelled", "REPORTS_TOTAL_BOOKINGS": "Total Bookings", "REPORTS_TOTAL_SITE_ATTENDANCE": "Total Site Attendance", - "REPORTS_TOTAL_RESERVATIONS": "Total Reservations", + "REPORTS_TOTAL_RESERVATIONS": "Requests", "REPORTS_TOTAL_TIME": "Total Booked Time", "REPORTS_TOTAL_VISITORS": "Total Visitors", "REPORTS_AVERAGE_LENGTH": "Average Length", "REPORTS_NO_SHOWS": "No shows", "REPORTS_NO_SHOWS_PERCENT": "% of No shows", "REPORTS_TOTAL_ATTENDEES": "Total Room Attendees", + "REPORTS_ACTIVE_BOOKINGS_HEADER": "Active Bookings", "REPORTS_DAILY_HEADER": "Daily Utilization", "REPORTS_DAILY_EMPTY": "No bookings for the select date range", "REPORTS_APPROVED": "Approved Bookings", diff --git a/shared/assets/locale/es.json b/shared/assets/locale/es.json index 89063ca4c2..2d1aab3867 100644 --- a/shared/assets/locale/es.json +++ b/shared/assets/locale/es.json @@ -559,6 +559,9 @@ "VISITOR_EMAIL": "Correo Electrónico del Visitante", "VISITOR_EMAIL_PLACEHOLDER": "Correo electrónico del visitante", "VISITOR_COMPANY": "Empresa del Visitante", + "VISITOR_COMPANY_PLACEHOLDER": "Empresa del visitante", + "VISITOR_PHONE": "Número de teléfono", + "VISITOR_PHONE_PLACEHOLDER": "Número de teléfono del visitante", "VISITOR_REASON": "Motivo de la visita", "VISITOR_REASON_PLACEHOLDER": "ej. Reunión de seguimiento", "VISITOR_PASS_PLACEHOLDER": "ej. V-1234", @@ -568,8 +571,12 @@ "VISITOR_SENT_SINGLE": "Invitación de visitante enviada a {{ name }}", "VISITOR_SENT_MULTIPLE": "Invitación de visitante enviada a {{ count }} personas", "VISITOR_SENT_MSG": "Se ha enviado la invitación para asistir a {{ location }} desde el {{ date }} a las {{ time }}", + "VISITOR_SENT_MSG_MULTIPLE": "Se han enviado las invitaciones para asistir a {{ location }} desde el {{ date }} a las {{ time }}", "VISITOR_SENDING": "Enviando invitaciones a visitantes...", "VISITOR_BOOK_ANOTHER": "Reservar otro visitante", + "VISITOR_BOOK_ANOTHER_MULTIPLE": "Reservar más visitantes", + "SPACE_REMOVE": "Eliminar este espacio", + "SPACE_ADD_TO": "Añadir este espacio", "INDUCTED": "Inducido", "PASS_NUMBER": "Número de Pase", "ASSETS_ADD_HEADER": "Añadir Activos", @@ -1722,6 +1729,7 @@ "REPORTS_NO_SHOWS": "No Presentados", "REPORTS_NO_SHOWS_PERCENT": "% de No Presentados", "REPORTS_TOTAL_ATTENDEES": "Total de Asistentes a la Sala", + "REPORTS_ACTIVE_BOOKINGS_HEADER": "Reservas activas", "REPORTS_DAILY_HEADER": "Utilización Diaria", "REPORTS_DAILY_EMPTY": "No hay reservas para el rango de fechas seleccionado", "REPORTS_APPROVED": "Reservas Aprobadas", diff --git a/shared/assets/locale/fr-CA.json b/shared/assets/locale/fr-CA.json index 461314082d..b6170f49f9 100644 --- a/shared/assets/locale/fr-CA.json +++ b/shared/assets/locale/fr-CA.json @@ -559,6 +559,9 @@ "VISITOR_EMAIL": "Courriel du visiteur", "VISITOR_EMAIL_PLACEHOLDER": "Courriel du visiteur", "VISITOR_COMPANY": "Entreprise du visiteur", + "VISITOR_COMPANY_PLACEHOLDER": "Entreprise du visiteur", + "VISITOR_PHONE": "Numéro de téléphone", + "VISITOR_PHONE_PLACEHOLDER": "Numéro de téléphone du visiteur", "VISITOR_REASON": "Raison de la visite", "VISITOR_REASON_PLACEHOLDER": "ex. Rencontre de suivi", "VISITOR_PASS_PLACEHOLDER": "ex. V-1234", @@ -568,8 +571,12 @@ "VISITOR_SENT_SINGLE": "Invitation envoyée à {{ name }}", "VISITOR_SENT_MULTIPLE": "Invitation envoyée à {{ count }} personnes", "VISITOR_SENT_MSG": "L'invitation a été envoyée pour se présenter à {{ location }} du {{ date }} à {{ time }}", + "VISITOR_SENT_MSG_MULTIPLE": "Les invitations ont été envoyées pour se présenter à {{ location }} du {{ date }} à {{ time }}", "VISITOR_SENDING": "Envoi des invitations aux visiteurs...", "VISITOR_BOOK_ANOTHER": "Réserver un autre visiteur", + "VISITOR_BOOK_ANOTHER_MULTIPLE": "Réserver d'autres visiteurs", + "SPACE_REMOVE": "Retirer cet espace", + "SPACE_ADD_TO": "Ajouter cet espace", "INDUCTED": "Formé", "PASS_NUMBER": "Numéro de laissez-passer", "ASSETS_ADD_HEADER": "Ajouter des actifs", @@ -1722,6 +1729,7 @@ "REPORTS_NO_SHOWS": "Absences", "REPORTS_NO_SHOWS_PERCENT": "% d'absences", "REPORTS_TOTAL_ATTENDEES": "Total des participants en salle", + "REPORTS_ACTIVE_BOOKINGS_HEADER": "Réservations actives", "REPORTS_DAILY_HEADER": "Utilisation quotidienne", "REPORTS_DAILY_EMPTY": "Aucune réservation pour la plage de dates sélectionnée", "REPORTS_APPROVED": "Réservations approuvées", diff --git a/shared/assets/locale/fr.json b/shared/assets/locale/fr.json index 4b6d9554e5..db2856a420 100644 --- a/shared/assets/locale/fr.json +++ b/shared/assets/locale/fr.json @@ -559,6 +559,9 @@ "VISITOR_EMAIL": "Courriel du visiteur", "VISITOR_EMAIL_PLACEHOLDER": "Courriel du visiteur", "VISITOR_COMPANY": "Entreprise du visiteur", + "VISITOR_COMPANY_PLACEHOLDER": "Entreprise du visiteur", + "VISITOR_PHONE": "Numéro de téléphone", + "VISITOR_PHONE_PLACEHOLDER": "Numéro de téléphone du visiteur", "VISITOR_REASON": "Raison de la visite", "VISITOR_REASON_PLACEHOLDER": "ex. Rencontre", "VISITOR_PASS_PLACEHOLDER": "ex. V-1234", @@ -568,8 +571,12 @@ "VISITOR_SENT_SINGLE": "Invitation envoyée à {{ name }}", "VISITOR_SENT_MULTIPLE": "Invitation envoyée à {{ count }} personnes", "VISITOR_SENT_MSG": "L'invitation a été envoyée pour {{ location }} du {{ date }} à {{ time }}", + "VISITOR_SENT_MSG_MULTIPLE": "Les invitations ont été envoyées pour {{ location }} du {{ date }} à {{ time }}", "VISITOR_SENDING": "Envoi des invitations aux visiteurs...", "VISITOR_BOOK_ANOTHER": "Réserver pour un autre visiteur", + "VISITOR_BOOK_ANOTHER_MULTIPLE": "Réserver pour d'autres visiteurs", + "SPACE_REMOVE": "Supprimer cet espace", + "SPACE_ADD_TO": "Ajouter cet espace", "INDUCTED": "Formé", "PASS_NUMBER": "Numéro de laissez-passer", "ASSETS_ADD_HEADER": "Ajouter des ressources", @@ -1722,6 +1729,7 @@ "REPORTS_NO_SHOWS": "Absences", "REPORTS_NO_SHOWS_PERCENT": "% d’absences", "REPORTS_TOTAL_ATTENDEES": "Nombre total de participants en salle", + "REPORTS_ACTIVE_BOOKINGS_HEADER": "Réservations actives", "REPORTS_DAILY_HEADER": "Utilisation quotidienne", "REPORTS_DAILY_EMPTY": "Aucune réservation pour la plage de dates sélectionnée", "REPORTS_APPROVED": "Réservations approuvées", diff --git a/shared/assets/locale/it.json b/shared/assets/locale/it.json index acb317667a..9430547c74 100644 --- a/shared/assets/locale/it.json +++ b/shared/assets/locale/it.json @@ -559,6 +559,9 @@ "VISITOR_EMAIL": "Email Visitatore", "VISITOR_EMAIL_PLACEHOLDER": "Email del visitatore", "VISITOR_COMPANY": "Azienda Visitatore", + "VISITOR_COMPANY_PLACEHOLDER": "Azienda del visitatore", + "VISITOR_PHONE": "Numero di telefono", + "VISITOR_PHONE_PLACEHOLDER": "Numero di telefono del visitatore", "VISITOR_REASON": "Motivo della visita", "VISITOR_REASON_PLACEHOLDER": "es. Riunione di aggiornamento", "VISITOR_PASS_PLACEHOLDER": "es. V-1234", @@ -568,8 +571,12 @@ "VISITOR_SENT_SINGLE": "Invito al visitatore inviato a {{ name }}", "VISITOR_SENT_MULTIPLE": "Invito al visitatore inviato a {{ count }} persone", "VISITOR_SENT_MSG": "L'invito è stato inviato per partecipare a {{ location }} dal {{ date }} alle {{ time }}", + "VISITOR_SENT_MSG_MULTIPLE": "Gli inviti sono stati inviati per partecipare a {{ location }} dal {{ date }} alle {{ time }}", "VISITOR_SENDING": "Invio inviti ai visitatori...", "VISITOR_BOOK_ANOTHER": "Prenota un altro visitatore", + "VISITOR_BOOK_ANOTHER_MULTIPLE": "Prenota altri visitatori", + "SPACE_REMOVE": "Rimuovi questo spazio", + "SPACE_ADD_TO": "Aggiungi questo spazio", "INDUCTED": "Abilitato", "PASS_NUMBER": "Numero Pass", "ASSETS_ADD_HEADER": "Aggiungi Risorse", @@ -1722,6 +1729,7 @@ "REPORTS_NO_SHOWS": "Assenti", "REPORTS_NO_SHOWS_PERCENT": "% di assenti", "REPORTS_TOTAL_ATTENDEES": "Partecipanti totali (stanze)", + "REPORTS_ACTIVE_BOOKINGS_HEADER": "Prenotazioni attive", "REPORTS_DAILY_HEADER": "Utilizzo giornaliero", "REPORTS_DAILY_EMPTY": "Nessuna prenotazione per l'intervallo di date selezionato", "REPORTS_APPROVED": "Prenotazioni approvate", diff --git a/shared/assets/locale/ja-JP.json b/shared/assets/locale/ja-JP.json index 8d759f782c..914738e726 100644 --- a/shared/assets/locale/ja-JP.json +++ b/shared/assets/locale/ja-JP.json @@ -559,6 +559,9 @@ "VISITOR_EMAIL": "訪問者のメールアドレス", "VISITOR_EMAIL_PLACEHOLDER": "訪問者のメールアドレス", "VISITOR_COMPANY": "訪問者の会社", + "VISITOR_COMPANY_PLACEHOLDER": "訪問者の会社", + "VISITOR_PHONE": "電話番号", + "VISITOR_PHONE_PLACEHOLDER": "訪問者の電話番号", "VISITOR_REASON": "訪問理由", "VISITOR_REASON_PLACEHOLDER": "例:打ち合わせ", "VISITOR_PASS_PLACEHOLDER": "例:V-1234", @@ -568,8 +571,12 @@ "VISITOR_SENT_SINGLE": "訪問者の招待を{{ name }}に送信しました", "VISITOR_SENT_MULTIPLE": "訪問者の招待を{{ count }}人に送信しました", "VISITOR_SENT_MSG": "{{ location }}への{{ date }} {{ time }}からの出席招待が送信されました", + "VISITOR_SENT_MSG_MULTIPLE": "{{ location }}への{{ date }} {{ time }}からの出席招待が送信されました", "VISITOR_SENDING": "訪問者の招待を送信中...", "VISITOR_BOOK_ANOTHER": "別の訪問者を予約", + "VISITOR_BOOK_ANOTHER_MULTIPLE": "他の訪問者を予約", + "SPACE_REMOVE": "このスペースを削除", + "SPACE_ADD_TO": "このスペースを追加", "INDUCTED": "研修済み", "PASS_NUMBER": "パス番号", "ASSETS_ADD_HEADER": "資産を追加", @@ -1722,6 +1729,7 @@ "REPORTS_NO_SHOWS": "無断キャンセル", "REPORTS_NO_SHOWS_PERCENT": "無断キャンセル率 %", "REPORTS_TOTAL_ATTENDEES": "総会議室出席者数", + "REPORTS_ACTIVE_BOOKINGS_HEADER": "有効な予約", "REPORTS_DAILY_HEADER": "日次利用率", "REPORTS_DAILY_EMPTY": "選択された日付範囲に予約はありません", "REPORTS_APPROVED": "承認済み予約", diff --git a/shared/assets/locale/pt.json b/shared/assets/locale/pt.json index 87bc83f5e3..9adb182bce 100644 --- a/shared/assets/locale/pt.json +++ b/shared/assets/locale/pt.json @@ -559,6 +559,9 @@ "VISITOR_EMAIL": "E-mail do Visitante", "VISITOR_EMAIL_PLACEHOLDER": "E-mail do visitante", "VISITOR_COMPANY": "Empresa do Visitante", + "VISITOR_COMPANY_PLACEHOLDER": "Empresa do visitante", + "VISITOR_PHONE": "Número de telefone", + "VISITOR_PHONE_PLACEHOLDER": "Número de telefone do visitante", "VISITOR_REASON": "Motivo da visita", "VISITOR_REASON_PLACEHOLDER": "Ex: Reunião de Acompanhamento", "VISITOR_PASS_PLACEHOLDER": "ex: V-1234", @@ -568,8 +571,12 @@ "VISITOR_SENT_SINGLE": "Convite enviado para {{ name }}", "VISITOR_SENT_MULTIPLE": "Convite enviado para {{ count }} pessoas", "VISITOR_SENT_MSG": "O convite foi enviado para comparecer em {{ location }} a partir de {{ date }} às {{ time }}", + "VISITOR_SENT_MSG_MULTIPLE": "Os convites foram enviados para comparecer em {{ location }} a partir de {{ date }} às {{ time }}", "VISITOR_SENDING": "Enviando convites de visitante...", "VISITOR_BOOK_ANOTHER": "Reservar outro visitante", + "VISITOR_BOOK_ANOTHER_MULTIPLE": "Reservar mais visitantes", + "SPACE_REMOVE": "Remover este espaço", + "SPACE_ADD_TO": "Adicionar este espaço", "INDUCTED": "Treinado", "PASS_NUMBER": "Número do Passe", "ASSETS_ADD_HEADER": "Adicionar Ativos", @@ -1722,6 +1729,7 @@ "REPORTS_NO_SHOWS": "Ausências", "REPORTS_NO_SHOWS_PERCENT": "% de Ausências", "REPORTS_TOTAL_ATTENDEES": "Total de Participantes em Salas", + "REPORTS_ACTIVE_BOOKINGS_HEADER": "Reservas ativas", "REPORTS_DAILY_HEADER": "Utilização Diária", "REPORTS_DAILY_EMPTY": "Nenhuma reserva para o intervalo de datas selecionado", "REPORTS_APPROVED": "Reservas Aprovadas", diff --git a/shared/assets/locale/zh.json b/shared/assets/locale/zh.json index ff4bbd95e2..49864069f7 100644 --- a/shared/assets/locale/zh.json +++ b/shared/assets/locale/zh.json @@ -559,6 +559,9 @@ "VISITOR_EMAIL": "访客电子邮件", "VISITOR_EMAIL_PLACEHOLDER": "访客的电子邮件", "VISITOR_COMPANY": "访客公司", + "VISITOR_COMPANY_PLACEHOLDER": "访客的公司", + "VISITOR_PHONE": "电话号码", + "VISITOR_PHONE_PLACEHOLDER": "访客的电话号码", "VISITOR_REASON": "访问原因", "VISITOR_REASON_PLACEHOLDER": "例如:会议跟进", "VISITOR_PASS_PLACEHOLDER": "例如:V-1234", @@ -568,8 +571,12 @@ "VISITOR_SENT_SINGLE": "访客邀请已发送给 {{ name }}", "VISITOR_SENT_MULTIPLE": "访客邀请已发送给 {{ count }} 人", "VISITOR_SENT_MSG": "邀请已发送,邀请您于 {{ date }} {{ time }} 访问 {{ location }}", + "VISITOR_SENT_MSG_MULTIPLE": "邀请已发送,邀请大家于 {{ date }} {{ time }} 访问 {{ location }}", "VISITOR_SENDING": "正在发送访客邀请...", "VISITOR_BOOK_ANOTHER": "预订另一位访客", + "VISITOR_BOOK_ANOTHER_MULTIPLE": "预订更多访客", + "SPACE_REMOVE": "移除此空间", + "SPACE_ADD_TO": "添加此空间", "INDUCTED": "已入职培训", "PASS_NUMBER": "通行证号码", "ASSETS_ADD_HEADER": "添加资产", @@ -1722,6 +1729,7 @@ "REPORTS_NO_SHOWS": "未到场", "REPORTS_NO_SHOWS_PERCENT": "未到场百分比", "REPORTS_TOTAL_ATTENDEES": "总房间与会者", + "REPORTS_ACTIVE_BOOKINGS_HEADER": "活跃预订", "REPORTS_DAILY_HEADER": "每日利用率", "REPORTS_DAILY_EMPTY": "所选日期范围没有预订", "REPORTS_APPROVED": "已批准的预订", diff --git a/shared/styles/application.css b/shared/styles/application.css index bf4d9d655f..9d3868192e 100644 --- a/shared/styles/application.css +++ b/shared/styles/application.css @@ -862,7 +862,8 @@ a[btn] { &.inverse { color: var(--bg-color); - background-color: transparent; + background-color: color-mix(in srgb, var(--bg-color) 15%, transparent); + backdrop-filter: blur(4px); } &.clear { @@ -904,6 +905,16 @@ a[btn] { } } +.theme-dark { + button[btn]:not([icon]), + a[btn] { + &.inverse { + --bg-color: #fff; + --color: #000; + } + } +} + #placeos .mdc-icon-button, button[icon], a[icon] { @@ -916,6 +927,7 @@ a[icon] { font-size: 1em; padding: 0; border-radius: 9999px; + cursor: pointer; .mdc-button-wrapper { display: flex; @@ -971,6 +983,26 @@ a[icon] { } } +@keyframes sidebar-dialog-in-left { + from { + transform: translateX(-100%); + } + + to { + transform: translateX(0); + } +} + +@keyframes sidebar-dialog-in-right { + from { + transform: translateX(100%); + } + + to { + transform: translateX(0); + } +} + #placeos { mat-select { .mdc-select-trigger { @@ -1045,6 +1077,26 @@ a[icon] { max-width: none !important; } + .sidebar-embed-dialog .mat-mdc-dialog-surface { + border-radius: 0; + overflow: hidden; + padding: 0; + } + + .sidebar-embed-dialog-left .mat-mdc-dialog-surface { + animation: sidebar-dialog-in-left 200ms ease-out; + } + + .sidebar-embed-dialog-right .mat-mdc-dialog-surface { + animation: sidebar-dialog-in-right 200ms ease-out; + } + + .svg-viewer__label { + top: -200%; + left: -200%; + line-height: 1.1; + } + .mdc-datepicker-toggle { button { position: relative; @@ -1359,11 +1411,14 @@ a[icon] { .mdc-notched-outline__leading, .mdc-notched-outline__trailing { - border-color: var(--neutral); + border-color: var(--base-400); } .mdc-text-field__input { color: var(--base-content); + &::placeholder { + color: var(--base-400); + } } .mdc-text-field--focused { @@ -1555,6 +1610,130 @@ survey-widget *::-webkit-scrollbar-thumb { } } +#placeos .sun-editor { + border-radius: 0.5rem 0.5rem 0.25rem 0.25rem; + background-color: var(--base-100); + border-color: var(--base-300); + .se-toolbar { + border-radius: 0.5rem 0.5rem 0 0; + background-color: var(--base-200); + border-color: var(--base-300); + outline-color: var(--base-300); + } + .se-btn { + color: var(--base-content); + border-color: var(--base-300); + outline-color: var(--base-300); + + &.on { + background-color: var(--base-300); + } + } + + .se-btn-list.default_value { + color: var(--base-content); + border-color: var(--base-300); + background-color: var(--base-200); + } + + .se-btn-module { + color: var(--base-content); + border-color: var(--base-400); + } + + .se-btn:hover, + .se-btn:focus { + background-color: var(--base-300); + color: var(--base-content); + } + + .se-btn.active { + background-color: var(--secondary); + color: var(--secondary-content); + border-color: var(--secondary); + } + + .se-wrapper, + .se-wrapper-inner { + background-color: var(--base-100); + } + + .se-wrapper-wysiwyg { + color: var(--base-content); + } + + .se-placeholder { + color: var(--base-300); + } + + .se-resizing-bar { + border-radius: 0 0 0.25rem 0.25rem; + background-color: var(--base-200); + border-color: var(--base-300); + } + + .se-dialog { + background-color: var(--base-100); + border-color: var(--base-300); + } + .se-dialog-header { + background-color: var(--base-200); + border-color: var(--base-300); + } + + .se-dialog-content { + background-color: var(--base-100); + } + + .se-dialog-body { + color: var(--base-content); + } + + .se-dialog-form-footer { + background-color: var(--base-200); + border-color: var(--base-300); + } + + .se-dropdown-list { + background-color: var(--base-100); + border-color: var(--base-300); + } + + .se-list-layer { + background-color: var(--base-100); + border-color: var(--base-300); + } + + .se-list-inner li button { + color: var(--base-content); + } + + .se-list-inner li button:hover, + .se-list-inner li button:focus { + background-color: var(--base-200); + } + + .se-input-form, + .se-input-select { + background-color: var(--base-100); + color: var(--base-content); + border-color: var(--base-300); + } + + .se-input-form:focus, + .se-input-select:focus { + border-color: var(--primary); + } + + /* Custom plugin buttons in dark mode */ + .se-toolbar button i.material-symbols-outlined { + color: var(--base-content); + } + .se-toolbar button:hover i.material-symbols-outlined { + color: var(--base-content); + } +} + /* Native Classes */ input, @@ -1622,7 +1801,7 @@ a[link] { ::placeholder, .mat-form-field-label, .mat-mdc-select-placeholder { - color: var(--neutral); + color: var(--base-300); } input {