From b61ae68e8bddc4af5fb4b3b765aa69a1c7dec85a Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Sat, 1 Nov 2025 15:15:50 +1100 Subject: [PATCH 001/392] chore(workplace): start adding new flows --- apps/workplace/src/app/app-routing.module.ts | 23 +- apps/workplace/src/app/book/book.module.ts | 5 +- .../meeting-flow-details.component.ts | 153 +++++ .../meeting-flow-new.component.ts | 71 +++ .../meeting-flow-options.component.ts | 304 ++++++++++ .../meeting-flow-space-select.component.ts | 566 +++++++++++++++++ .../meeting-flow.component.ts | 106 ++++ .../app/landing-new/chat-view.component.ts | 126 ++++ .../app/landing-new/landing-new.component.ts | 567 ++++++++++++++++++ shared/assets/icons/ben_icon.svg | 23 + 10 files changed, 1936 insertions(+), 8 deletions(-) create mode 100644 apps/workplace/src/app/book/meeting-flow-new/meeting-flow-details.component.ts create mode 100644 apps/workplace/src/app/book/meeting-flow-new/meeting-flow-new.component.ts create mode 100644 apps/workplace/src/app/book/meeting-flow-new/meeting-flow-options.component.ts create mode 100644 apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-select.component.ts create mode 100644 apps/workplace/src/app/book/meeting-flow-new/meeting-flow.component.ts create mode 100644 apps/workplace/src/app/landing-new/chat-view.component.ts create mode 100644 apps/workplace/src/app/landing-new/landing-new.component.ts create mode 100644 shared/assets/icons/ben_icon.svg diff --git a/apps/workplace/src/app/app-routing.module.ts b/apps/workplace/src/app/app-routing.module.ts index 6dfeba0d9cb..638205807aa 100644 --- a/apps/workplace/src/app/app-routing.module.ts +++ b/apps/workplace/src/app/app-routing.module.ts @@ -11,13 +11,13 @@ const routes: Routes = [ { path: '-', component: RedirectComponent }, { path: 'unauthorised', component: UnauthorisedComponent }, { path: 'misconfigured', component: MisconfiguredComponent }, - { - path: 'landing', - canActivate: [AuthorisedUserGuard], - canLoad: [AuthorisedUserGuard], - loadChildren: () => - import('./landing/landing.module').then((m) => m.AppLandingModule), - }, + // { + // path: 'landing', + // canActivate: [AuthorisedUserGuard], + // canLoad: [AuthorisedUserGuard], + // loadChildren: () => + // import('./landing/landing.module').then((m) => m.AppLandingModule), + // }, { path: 'book', canActivate: [AuthorisedUserGuard], @@ -73,6 +73,15 @@ const routes: Routes = [ loadChildren: () => import('./deals/deals.module').then((m) => m.DealsModule), }, + { + path: 'landing', + canActivate: [AuthorisedUserGuard], + canLoad: [AuthorisedUserGuard], + loadComponent: () => + import('./landing-new/landing-new.component').then( + (m) => m.LandingNewComponent, + ), + }, { path: '**', redirectTo: '-', pathMatch: 'full' }, ]; diff --git a/apps/workplace/src/app/book/book.module.ts b/apps/workplace/src/app/book/book.module.ts index 2bd566271ec..be2143bac66 100644 --- a/apps/workplace/src/app/book/book.module.ts +++ b/apps/workplace/src/app/book/book.module.ts @@ -7,6 +7,7 @@ import { CodeFlowSuccessComponent } from './code-flow-success.component'; import { BookCodeFlowComponent } from './code-flow.component'; import { NewDeskFlowComponent } from './desk-flow.component'; import { BookLockerFlowComponent } from './locker-flow.component'; +import { MeetingFlowNewComponent } from './meeting-flow-new/meeting-flow-new.component'; import { BookMeetingFlowComponent } from './meeting-flow.component'; import { NewParkingFlowComponent } from './parking-flow.component'; import { VisitorFlowComponent } from './visitor-flow.component'; @@ -23,7 +24,9 @@ const ROUTES: Route[] = [ { path: 'desks', redirectTo: 'desk/form' }, { path: 'desk/:step', component: NewDeskFlowComponent }, { path: 'meeting', redirectTo: 'meeting/form' }, - { path: 'meeting/:step', component: BookMeetingFlowComponent }, + { path: 'meeting/:step', component: MeetingFlowNewComponent }, + // { path: 'meeting', redirectTo: 'meeting/form' }, + // { path: 'meeting/:step', component: BookMeetingFlowComponent }, { path: 'parking', redirectTo: 'parking/form' }, { path: 'parking/:step', component: NewParkingFlowComponent }, { path: 'visitor', redirectTo: 'visitor/form' }, 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 00000000000..8ad585bccd1 --- /dev/null +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-details.component.ts @@ -0,0 +1,153 @@ +import { Component, inject, signal } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { IconComponent } from '@placeos/components'; +import { EventFormService } from '@placeos/events'; +import { + DateFieldComponent, + DurationFieldComponent, + TimeFieldComponent, +} from '@placeos/form-fields'; + +@Component({ + selector: 'meeting-flow-details', + template: ` +
+
+ info +
Meeting Details
+
+
+ + + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ info +
Room Size
+
+
+ + + + +
+
+ +
+
+ `, + styles: [ + ` + .gradient { + background: linear-gradient( + 105deg, + var(--base-200) 0%, + var(--base-200) 50%, + var(--base-100) 100% + ); + } + `, + ], + imports: [ + IconComponent, + MatFormFieldModule, + MatInputModule, + DateFieldComponent, + DurationFieldComponent, + TimeFieldComponent, + FormsModule, + ReactiveFormsModule, + ], +}) +export class MeetingFlowDetailsComponent { + private _event_form = inject(EventFormService); + + public readonly form = signal(this._event_form.form); +} diff --git a/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-new.component.ts b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-new.component.ts new file mode 100644 index 00000000000..98807822ed7 --- /dev/null +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-new.component.ts @@ -0,0 +1,71 @@ +import { Component, OnInit, inject, signal } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { AsyncHandler } from '@placeos/common'; +import { EventFormService } from '@placeos/events'; +import { MeetingFlowConfirmComponent } from '../meeting-flow/meeting-flow-confirm.component'; +import { MeetingFlowSuccessComponent } from '../meeting-flow/meeting-flow-success.component'; +import { MeetingFlowComponent } from './meeting-flow.component'; + +@Component({ + selector: 'placeos-book-meeting-flow', + template: ` +
+ @switch (view()) { + @case ('success') { + + } + @case ('confirm') { + + } + @default { + + } + } +
+ `, + styles: [ + ` + :host { + height: 100%; + width: 100%; + } + `, + ], + imports: [ + MeetingFlowSuccessComponent, + MeetingFlowConfirmComponent, + MeetingFlowComponent, + ], +}) +export class MeetingFlowNewComponent extends AsyncHandler implements OnInit { + private _state = inject(EventFormService); + private _route = inject(ActivatedRoute); + + public readonly view = signal('form'); + public readonly last_success = this._state.last_success; + + public ngOnInit() { + this._state.loadForm(); + this.subscription( + 'state:view', + this._state.view$.subscribe((v) => this.view.set(v)), + ); + this.subscription( + 'state:rooms', + this._state.available_spaces.subscribe(), + ); + this.subscription( + 'route.params', + this._route.paramMap.subscribe((param) => { + if (param.has('step')) + this._state.setView(param.get('step') as any); + }), + ); + this.subscription( + 'route.query', + this._route.queryParamMap.subscribe((param) => { + if (param.has('success')) this._state.setView('success'); + }), + ); + } +} 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 00000000000..238b131dc9b --- /dev/null +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-options.component.ts @@ -0,0 +1,304 @@ +import { CommonModule, DatePipe } from '@angular/common'; +import { Component, inject, signal } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { AssetListFieldComponent } from '@placeos/assets'; +import { + CateringListFieldComponent, + CateringOrderStateService, +} from '@placeos/catering'; +import { + OrganisationService, + settingSignal, + SettingsService, +} from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { EventFormService } from '@placeos/events'; +import { + RichTextInputComponent, + UserListFieldComponent, +} from '@placeos/form-fields'; +import { BehaviorSubject, combineLatest, map, tap } from 'rxjs'; + +@Component({ + selector: 'meeting-flow-options', + template: ` +
+
+ task_alt +
Review & Confirm Booking
+
+
+
+
+
+ done +
+

+ {{ event.title || 'Meeting Details' }} +

+
+
+
+ today +
{{ event.date | date: 'fullDate' }}
+
+ @if (event.recurrence?.pattern) { +
+ update +
{{ formatted_recurrence }}
+
+ } +
+ schedule +
+
{{ formattedTime() }}
+ @if (timezone) { +
+ {{ formattedTime(tz) }} +
+ } +
+
+
+
+
+
+ fork_right +
Optional Extras
+
+
+

Attendees

+ + @if (has_catering | async) { +

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

+ + @if (event.catering?.length && has_codes | async) { + + + + + @for ( + code of filtered_codes | async; + track code + ) { + + {{ code }} + + } + + + {{ + 'CALENDAR_EVENT.CATERING_CHARGE_CODE_REQUIRED' + | translate + }} + + + } + @if (event.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, + ReactiveFormsModule, + FormsModule, + CateringListFieldComponent, + AssetListFieldComponent, + RichTextInputComponent, + ], +}) +export class MeetingFlowOptionsComponent { + private _event_form = inject(EventFormService); + private _settings = inject(SettingsService); + private _org = inject(OrganisationService); + private _catering = inject(CateringOrderStateService); + private _date = new DatePipe('en'); + + public readonly form = signal(this._event_form.form); + 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 code_filter = new BehaviorSubject(''); + + public get is_multiday() { + return this.form().getRawValue().duration > 24 * 60; + } + + public readonly has_catering = this._catering.available_menu.pipe( + map((l) => l.length > 0), + ); + + public readonly has_codes = this._catering.charge_codes.pipe( + map((l) => l.length > 0), + tap((has_codes) => { + if (!has_codes) { + this.form().get('catering_charge_code').setValidators([]); + this.form().updateValueAndValidity(); + } + }), + ); + + public readonly filtered_codes = combineLatest([ + this.code_filter, + this._catering.charge_codes, + ]).pipe( + map(([s, l]) => + l.filter((_) => _.toLowerCase().includes(s.toLowerCase())), + ), + ); + + public get time_format() { + return this._settings.time_format; + } + + public get event() { + return this.form().getRawValue(); + } + + public get timezone() { + return this._settings.get('app.events.use_building_timezone') + ? this._org.building.timezone + : ''; + } + + public formattedTime(tz?: string) { + const date = this.form().getRawValue().date; + const date_end = this.form().getRawValue().date_end; + const all_day = this.form().getRawValue().all_day; + const tz_format = this._date.transform(date, 'zzzz', tz); + const start_date = this._date.transform(date, 'MMM d', tz); + const start_time = this._date.transform(date, this.time_format, tz); + const end_date = this._date.transform(date_end, 'MMM d', tz); + const end_time = this._date.transform(date_end, this.time_format, tz); + + if (this.is_multiday) { + return `${start_date}${all_day ? '' : ', ' + start_time} - ${end_date}${all_day ? '' : ', ' + end_time}`; + } else if (all_day) { + return 'All Day'; + } + return `${start_time} - ${end_time} ${'(' + tz_format + ')'}`; + } +} 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 00000000000..5c2d36dd2a0 --- /dev/null +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-select.component.ts @@ -0,0 +1,566 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, inject, signal } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatRippleModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { + flatten, + OrganisationService, + settingSignal, + SettingsService, + unique, +} from '@placeos/common'; +import { + BuildingPipe, + IconComponent, + LevelPipe, + SettingsToggleComponent, + TranslatePipe, +} from '@placeos/components'; +import { EventFormService, SpacesService } from '@placeos/events'; +import { + DateFieldComponent, + DurationFieldComponent, + TimeFieldComponent, +} from '@placeos/form-fields'; +import { combineLatest, map } from 'rxjs'; + +const FAV_SPACE_KEY = 'favourite_rooms'; + +@Component({ + selector: 'meeting-flow-space-select', + template: ` +
+
+ info +
Select Meeting Room
+
+
+
+
+
+
+

Filters

+ +
+
+ + @if (use_region() && (regions | async)?.length) { + + + @for (reg of regions | async; track reg) { + + {{ reg.display_name || reg.name }} + + } + + + } + @if (!use_region() && (buildings | async)?.length > 1) { + + + @for (bld of buildings | async; track bld) { + + {{ bld.display_name || bld.name }} + + } + + + } + + + @for (lvl of levels | async; track lvl) { + +
+ @if (use_region) { +
+ {{ + ( + lvl?.parent_id + | building + )?.display_name + }} + + - + +
+ } +
+ {{ + lvl.display_name || lvl.name + }} +
+
+
+ } +
+
+ + + All Day + @if (!field('all_day')) { + +
+ + +
+ } + Favourites Only +

Facilities

+
+ @for (feat of features | async; track feat) { + @if (!hide_features().includes(feat)) { + + } + } +
+
+
+
+ @if (!(loading | async)) { + @if ((available_spaces | async)?.length) { +
    + @for ( + space of available_spaces | async; + track space + ) { +
  • + + + @if (space.approval) { +
    + {{ + 'COMMON.APPROVAL_REQUIRED' + | translate + }} +
    + } +
  • + } +
+ } @else { +
+

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

+
+ } + } @else { +
+ +

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

+
+ } +
+
+
+ +
+
+ `, + styles: [ + ` + .gradient { + background: linear-gradient( + 105deg, + var(--base-200) 0%, + var(--base-200) 50%, + var(--base-100) 100% + ); + } + `, + ], + imports: [ + AsyncPipe, + MatRippleModule, + IconComponent, + MatFormFieldModule, + MatSelectModule, + DateFieldComponent, + TimeFieldComponent, + DurationFieldComponent, + ReactiveFormsModule, + FormsModule, + SettingsToggleComponent, + TranslatePipe, + BuildingPipe, + LevelPipe, + ], +}) +export class MeetingFlowSpaceSelectComponent { + private _event_form = inject(EventFormService); + private _org = inject(OrganisationService); + private _settings = inject(SettingsService); + private _spaces = inject(SpacesService); + + public readonly use_24hr = settingSignal('use_24_hour_time', false); + public readonly use_region = settingSignal('use_region', false); + public readonly max_duration = settingSignal('events.max_duration', 8 * 60); + public readonly feature_display = settingSignal>( + 'events.feature_decriptions', + {}, + ); + public readonly hide_features = settingSignal( + 'events.hide_features', + [], + ); + public readonly form = signal(this._event_form.form); + + 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 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) => this._event_form.setFilters(f); + 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 active = signal(''); + public readonly selected = signal(''); + public readonly favourites = settingSignal( + FAV_SPACE_KEY, + [], + true, + ); + + public readonly levels = combineLatest([ + this._org.active_region, + this._org.active_building, + ]).pipe( + map(([region, bld]) => { + const level_list = this.use_region + ? this._org.levelsForRegion(region) + : this._org.levelsForBuilding(bld); + const viewable_levels = level_list.filter( + (lvl) => !lvl.tags.includes('parking'), + ); + return viewable_levels.sort( + (a, b) => + a.parent_id.localeCompare(b.parent_id) || + (a.display_name || '').localeCompare(b.display_name || ''), + ); + }), + ); + + public readonly features = combineLatest([ + this._spaces.features, + this._event_form.available_spaces, + ]).pipe( + map(([features, spaces]) => + unique(features.concat(flatten(spaces.map((_) => _.features)))), + ), + ); + + public field(name: string) { + return this.form()?.getRawValue()?.[name]; + } + + public get timezone() { + return this._settings.get('app.events.use_building_timezone') + ? this._org.building.timezone + : ''; + } + + 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 isFavourite(space_id: string) { + return this.favourites().includes(space_id); + } +} 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 00000000000..f79788c977c --- /dev/null +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow.component.ts @@ -0,0 +1,106 @@ +import { Component, signal } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { IconComponent } from '@placeos/components'; +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', + template: ` +
+
+
+

+ Book a meeting room +

+
+ +
+ +
+ +
+
+
+ @switch (view()) { + @case (0) { + + } + @case (1) { + + } + @case (2) { + + } + } +
+
+
+ `, + styles: [``], + imports: [ + IconComponent, + MatRippleModule, + MeetingFlowDetailsComponent, + MeetingFlowSpaceSelectComponent, + MeetingFlowOptionsComponent, + ], +}) +export class MeetingFlowComponent { + public readonly view = signal(0); +} 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 00000000000..9bf5a477601 --- /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-new.component.ts b/apps/workplace/src/app/landing-new/landing-new.component.ts new file mode 100644 index 00000000000..80e52e0c4c8 --- /dev/null +++ b/apps/workplace/src/app/landing-new/landing-new.component.ts @@ -0,0 +1,567 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, signal } from '@angular/core'; +import { MatBottomSheet } from '@angular/material/bottom-sheet'; +import { MatRippleModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { RouterModule } from '@angular/router'; +import { + FAV_DESK_KEY, + FAV_LOCKER_KEY, + FAV_PARKING_KEY, + LockerBank, + ParkingSpace, +} from '@placeos/bookings'; +import { Desk, settingSignal, Space } from '@placeos/common'; +import { + IconComponent, + TranslatePipe, + UserAvatarComponent, +} from '@placeos/components'; +import { ExploreSpacesService } from '@placeos/explore'; +import { FooterMenuComponent } from '../components/footer-menu.component'; +import { TopbarComponent } from '../components/topbar.component'; +import { LandingStateService } from '../landing/landing-state.service'; +import { ChatViewComponent } from './chat-view.component'; + +interface FavoriteEntry { + id: string; + name: string; + type: 'space' | 'desk' | 'locker' | 'parking'; + zones: string[]; +} + +@Component({ + selector: 'landing-new', + template: ` +
+ @if (!hide_nav()) { + + } +
+
+
+
+
+
+
+ {{ + 'CALENDAR_EVENT.GROUP_FEATURED' + | translate + }} +
+
+
+ timelapse +
Starts in X minutes
+
+
+
+
+

+ Your next booking +

+

+ Meeting Room A - Floor 3 +

+
+ event +
10:00 AM - 11:00 AM · Today
+
+
+ distance +
Conference Room, Level 4
+
+
+ people +
5 attendees
+
+
+
+ + + +
+
+
+ +
+ {{ + 'APP.WORKPLACE.UPCOMING_VIEW_BOOKINGS' + | translate + }} +
+ arrow_forward +
+
+
+
+

+ Available Now +

+ +
+
+ + + +
+
+ @for (lvl of levels_free | async; track lvl) { + + desk +
+ {{ lvl.display_name || lvl.name }} +
+
+ 0 free +
+ chevron_right +
+ } +
+
+ +
+
+
+
+

Favourites

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

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

+
+ } +
+
+ @let contact_list = contacts | async; +
+

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

+
+ {{ + 'APP.WORKPLACE.COLLEAGUES_COUNT' + | translate + : { + count: contact_list?.length, + } + : contact_list?.length + }} +
+
+ @if (contact_list?.length) { +
+ @for (user of contact_list; track user) { +
+ +
+
+ {{ user.name }} +
+ {{ user.email }} +
+ +
+ } +
+ } @else { +
+ +

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

+
+ } + +
+
+
+
+ +
+ @if (!hide_nav()) { + + } +
+
+ `, + styles: [ + ` + .blur { + filter: blur(10px); + } + + .bg-grad { + background: linear-gradient( + 105deg, + var(--brand-100) 0%, + var(--brand-200) 100% + ); + } + `, + ], + providers: [ExploreSpacesService], + imports: [ + CommonModule, + MatRippleModule, + TopbarComponent, + FooterMenuComponent, + TranslatePipe, + IconComponent, + RouterModule, + MatFormFieldModule, + MatSelectModule, + UserAvatarComponent, + MatTooltipModule, + ], +}) +export class LandingNewComponent { + public readonly hide_nav = signal(false); + public readonly active_available = signal('desks'); + public readonly open_concierge = signal(false); + + // Availability + private _state = inject(LandingStateService); + private _bottom_sheet = inject(MatBottomSheet); + private _explore = inject(ExploreSpacesService); + + public readonly space_list = this._state.free_space_list; + public readonly loading_spaces = this._state.loading_spaces; + public readonly levels_free = this._state.level_occupancy; + public readonly hide_spaces = settingSignal('hide_landing_spaces'); + public readonly hide_rooms = settingSignal('hide_landing_rooms'); + + public readonly spaces = settingSignal( + 'favourite_spaces', + [], + true, + ); + public readonly parking = settingSignal( + FAV_PARKING_KEY, + [], + true, + ); + public readonly desks = settingSignal(FAV_DESK_KEY, [], true); + public readonly lockers = settingSignal( + FAV_LOCKER_KEY, + [], + true, + ); + + public readonly favourites = computed(() => { + let list: FavoriteEntry[] = []; + list = list.concat( + this.spaces().map((s) => ({ + type: 'space', + id: s.id, + name: s.display_name || s.name, + zones: s.zones || [], + })), + ); + list = list.concat( + this.parking().map((p) => ({ + type: 'parking', + id: p.id, + name: p.name, + zones: (p as any).zones || [], + })), + ); + list = list.concat( + this.desks().map((d) => ({ + type: 'desk', + id: d.id, + name: d.name, + zones: [d.zone?.id], + })), + ); + list = list.concat( + this.lockers().map((l) => ({ + type: 'locker', + id: l.id, + name: l.name, + zones: l.zones, + })), + ); + console.log('Favourites:', list); + return list; + }); + + public book = (s) => this._explore.bookSpace(s, true); + + // Contacts + public readonly contacts = this._state.contacts; + + public openConciergeChat() { + this.open_concierge.set(true); + const ref = this._bottom_sheet.open(ChatViewComponent); + ref.afterDismissed().subscribe(() => this.open_concierge.set(false)); + } +} diff --git a/shared/assets/icons/ben_icon.svg b/shared/assets/icons/ben_icon.svg new file mode 100644 index 00000000000..fd1bf90d228 --- /dev/null +++ b/shared/assets/icons/ben_icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + From 7d8f1ec0454db237f10b4ff70cd2b1cfd73ae17f Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 3 Nov 2025 15:19:52 +1100 Subject: [PATCH 002/392] chore(workplace): various changes --- .../desk-flow/desk-flow-form.component.ts | 50 +- .../meeting-flow-details.component.ts | 220 ++++--- .../meeting-flow-options.component.ts | 12 +- .../meeting-flow-space-select.component.ts | 564 ++++++++++-------- .../meeting-flow.component.ts | 52 +- shared/styles/application.scss | 11 +- shared/styles/custom-element.scss | 5 +- 7 files changed, 565 insertions(+), 349 deletions(-) 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 5c3bce9605f..b3433781b8b 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 { @@ -13,7 +14,7 @@ import { OrganisationService, SettingsService, } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; +import { IconComponent, TranslatePipe } from '@placeos/components'; import { isBefore, startOfMinute } from 'date-fns'; import { lastValueFrom } from 'rxjs'; import { first } from 'rxjs/operators'; @@ -25,6 +26,46 @@ import { NewDeskFormDetailsComponent } from './desk-form-details.component'; styles: [], template: `
+
+
+
+
+
Quick Action
+
+
+ auto_awesome +
Smart Selection
+
+
+
+

Auto-Assign

+

+ We'll always try to book you in your home + neighbourhood—if it's full, we'll find you a spot + nearby. +

+
+
+ + +
+
@@ -55,7 +96,12 @@ import { NewDeskFormDetailsComponent } from './desk-form-details.component';
`, - imports: [TranslatePipe, NewDeskFormDetailsComponent], + imports: [ + TranslatePipe, + NewDeskFormDetailsComponent, + IconComponent, + MatRippleModule, + ], }) export class NewDeskFlowFormComponent implements OnInit { private _state = inject(BookingFormService); 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 index 8ad585bccd1..01e4accabaf 100644 --- 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 @@ -1,7 +1,10 @@ +import { AsyncPipe } from '@angular/common'; import { Component, inject, signal } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +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 { IconComponent } from '@placeos/components'; import { EventFormService } from '@placeos/events'; import { @@ -13,113 +16,144 @@ import { @Component({ selector: 'meeting-flow-details', template: ` -
+
- info -
Meeting Details
-
-
- - - -
-
- - -
-
- - -
-
- - info +
Meeting Details
+
+
+ + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ info +
Room Size
+
+
+ @let capacity = (options | async)?.capacity || -1; + + + + +
+
- info -
Room Size
-
-
- - -
+ -
- groups -
5-8 people
-
- - -
-
`, @@ -136,6 +170,8 @@ import { `, ], imports: [ + AsyncPipe, + MatRippleModule, IconComponent, MatFormFieldModule, MatInputModule, @@ -144,10 +180,16 @@ import { TimeFieldComponent, FormsModule, ReactiveFormsModule, + RouterModule, ], }) export class MeetingFlowDetailsComponent { private _event_form = inject(EventFormService); public readonly form = signal(this._event_form.form); + public readonly options = this._event_form.filters$; + + public readonly setCapacity = (capacity: number) => { + this._event_form.setFilters({ capacity }); + }; } 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 index 238b131dc9b..98ca8d46213 100644 --- 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 @@ -26,7 +26,7 @@ import { BehaviorSubject, combineLatest, map, tap } from 'rxjs'; class="relative z-0 flex w-full flex-col overflow-hidden rounded-xl border border-base-300 bg-base-100" >
task_alt
Review & Confirm Booking
@@ -69,7 +69,7 @@ import { BehaviorSubject, combineLatest, map, tap } from 'rxjs';
fork_right
Optional Extras
@@ -196,7 +196,13 @@ import { BehaviorSubject, combineLatest, map, tap } from 'rxjs';
- +
`, styles: [ 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 index 5c2d36dd2a0..d58ffcb15b5 100644 --- 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 @@ -1,5 +1,5 @@ -import { AsyncPipe } from '@angular/common'; -import { Component, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Component, inject, output, signal } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -32,29 +32,23 @@ const FAV_SPACE_KEY = 'favourite_rooms'; selector: 'meeting-flow-space-select', template: `
info
Select Meeting Room
-
-
-
-
-
+
+
+

Filters

-
+
@if (use_region() && (regions | async)?.length) { @@ -188,33 +182,86 @@ const FAV_SPACE_KEY = 'favourite_rooms';
- @if (!(loading | async)) { - @if ((available_spaces | async)?.length) { -
    - @for ( - space of available_spaces | async; - track space - ) { -
  • - + } + @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 }} + } +
    +
    + {{ + 'CALENDAR_EVENT.SPACE_SELECT_SIZE_X' + | translate + : { + count: capacity || 2, + } + }} +
    + @for (feat of feature_list; track feat) { +
    +

    {{ feat }}

    + +
    + } +
+
+ @if (!(loading | async)) { + @if ((available_spaces | async)?.length) { +
    + @for ( + space of available_spaces | async; + track space + ) { +
  • -
    - @if ( - selected().includes( - space.id - ) - ) { -
    - done -
    - } - @if (space.images?.length) { - - } @else { - - } - @if ( +
    - } -
-
+ ? (room_alerts | async)[ + space.id + ][0] === 'closed' + : false + " + >
- {{ - space.display_name || - space.name || - 'Meeting Space' - }} + @if ( + selected().includes( + space.id + ) + ) { +
+ done +
+ } + @if (space.images?.length) { + + } @else { + + } + @if ( + (room_alerts | async)[ + space.id + ] + ) { +
+ {{ + (room_alerts + | async)[ + space.id + ][0] === 'warn' + ? 'warning' + : (room_alerts + | async)[ + space + .id + ][0] === + 'info' + ? 'info' + : 'close' + }} +
+ }
-
- place +
-

{{ - space.location || - ( - space.zones - | level - ) - ?.display_name || - ( - space.zones - | level - )?.name + space.display_name || + space.name || + 'Meeting Space' }} -

-
-
- people +
-

- {{ - 'CALENDAR_EVENT.CAPACITY_COUNT' - | translate - : { - count: - space.capacity < - 1 - ? 2 - : space.capacity, - } - }} -

+ place +

+ {{ + space.location || + ( + space.zones + | level + ) + ?.display_name || + ( + space.zones + | level + )?.name + }} +

+
+
+ people +

+ {{ + 'CALENDAR_EVENT.CAPACITY_COUNT' + | translate + : { + count: + space.capacity < + 1 + ? 2 + : space.capacity, + } + }} +

+
-
- - - @if (space.approval) { -
- {{ - 'COMMON.APPROVAL_REQUIRED' - | translate - }} -
- } - - } - + favorite + + @if (space.approval) { +
+ {{ + 'COMMON.APPROVAL_REQUIRED' + | translate + }} +
+ } + + } + + } @else { +
+

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

+
+ } } @else {
-

+ +

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

} - } @else { -
- -

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

-
- } +
-
- -
+
+
+
+ +
`, styles: [ @@ -453,10 +524,30 @@ const FAV_SPACE_KEY = 'favourite_rooms'; var(--base-100) 100% ); } + [filter-item] { + display: flex; + align-items: center; + padding: 0 1rem; + min-height: 2rem; + font-size: 0.875rem; + border: 1px solid rgba(0, 0, 0, 0.2); + 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: [ - AsyncPipe, + CommonModule, MatRippleModule, IconComponent, MatFormFieldModule, @@ -478,6 +569,9 @@ export class MeetingFlowSpaceSelectComponent { 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 max_duration = settingSignal('events.max_duration', 8 * 60); 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 index f79788c977c..495155ce03d 100644 --- 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 @@ -1,5 +1,7 @@ -import { Component, signal } from '@angular/core'; +import { Component, inject, OnInit, signal } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { AsyncHandler } from '@placeos/common'; import { IconComponent } from '@placeos/components'; import { MeetingFlowDetailsComponent } from './meeting-flow-details.component'; import { MeetingFlowOptionsComponent } from './meeting-flow-options.component'; @@ -9,7 +11,9 @@ import { MeetingFlowSpaceSelectComponent } from './meeting-flow-space-select.com selector: 'meeting-flow', template: `
-
+
@@ -17,10 +21,11 @@ import { MeetingFlowSpaceSelectComponent } from './meeting-flow-space-select.com Book a meeting room
- +
-
- +
- +
@@ -82,7 +88,10 @@ import { MeetingFlowSpaceSelectComponent } from './meeting-flow-space-select.com } @case (1) { - + } @case (2) { @@ -99,8 +108,27 @@ import { MeetingFlowSpaceSelectComponent } from './meeting-flow-space-select.com MeetingFlowDetailsComponent, MeetingFlowSpaceSelectComponent, MeetingFlowOptionsComponent, + RouterModule, ], }) -export class MeetingFlowComponent { +export class MeetingFlowComponent extends AsyncHandler implements OnInit { + private _route = inject(ActivatedRoute); + public readonly view = signal(0); + + public readonly previous = () => + this.view.update((u) => Math.max(0, u - 1)); + public readonly next = () => this.view.update((u) => u + 1); + + public ngOnInit() { + this.subscription( + 'route.query', + this._route.queryParamMap.subscribe((params) => { + if (params.has('view')) { + if (Number.isNaN(+params.get('view'))) return; + this.view.update((old) => +params.get('view')); + } + }), + ); + } } diff --git a/shared/styles/application.scss b/shared/styles/application.scss index a5c09387301..fec30817c6a 100644 --- a/shared/styles/application.scss +++ b/shared/styles/application.scss @@ -2,10 +2,10 @@ :root { /* Brand */ - --brand-100: hsl(336, 57%, 48%); /* #C52B67 */ + --brand-100: hsla(336, 57%, 48%, 1); /* #C52B67 */ --brand-200: hsla(217, 91%, 60%, 1); /* #C52B67 */ --brand-300: rgb(236, 239, 241); /* #C52B67 */ - --brand-400: hsl(336, 57%, 48%); /* #C52B67 */ + --brand-400: rgb(0, 121, 107); /* #C52B67 */ --brand-500: hsl(336, 57%, 48%); /* #C52B67 */ --brand-600: hsl(336, 57%, 48%); /* #C52B67 */ --brand-content: hsl(0, 0%, 100%); /* White content for contrast */ @@ -18,8 +18,8 @@ /* Secondary */ --secondary: hsl(237, 37%, 20%); /* #292F5B */ --secondary-focus: hsl(237, 37%, 10%); /* Darker shade */ - --secondary-content: hsl(0, 0%, 100%); /* White content for contrast */ - + --secondary-content: hsl(0, 0%, 100%); /* White content for contrast */ + /* Ternary */ --ternary: hsl(237, 37%, 20%); /* #292F5B */ --ternary-focus: hsl(237, 37%, 10%); /* Darker shade (Hover etc.) */ @@ -92,7 +92,7 @@ --secondary: hsl(336, 57%, 48%); /* #C52B67 */ --secondary-focus: hsl(336, 57%, 38%); /* Darker shade */ --secondary-content: hsl(0, 0%, 100%); /* White content for contrast */ - + /* Ternary */ --ternary: hsl(206, 23%, 30%); /* #E5E5EA */ --ternary-focus: hsl(206, 23%, 24%); /* Darker shade */ @@ -144,7 +144,6 @@ --error-content: hsl(0, 0%, 100%); /* White content on red background */ } - @media screen { .print-only { display: none !important; diff --git a/shared/styles/custom-element.scss b/shared/styles/custom-element.scss index 9b3c12a6b54..bb43b6f4d99 100644 --- a/shared/styles/custom-element.scss +++ b/shared/styles/custom-element.scss @@ -549,14 +549,15 @@ a[btn] { color: var(--color); padding: 0 1rem; min-height: 3rem; - border-radius: 0.25rem; + border-radius: 0.5rem; display: flex; align-items: center; justify-content: center; &.inverse { color: var(--bg-color); - background-color: transparent; + background-color: color-mix(in srgb, var(--bg-color) 15%, transparent); + @apply backdrop-blur-sm; } &.clear { From fe3e64e089805fc6adbc1d2182dea503290b2c3c Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Tue, 4 Nov 2025 00:13:36 +1100 Subject: [PATCH 003/392] chore(workplace): add logic to new meeting flow space select --- .../meeting-flow-space-list.component.ts | 258 ++++++++++ .../meeting-flow-space-map.component.ts | 170 +++++++ .../meeting-flow-space-select.component.ts | 445 +++++------------- .../meeting-flow.component.ts | 2 +- libs/common/src/lib/settings.service.ts | 11 +- 5 files changed, 559 insertions(+), 327 deletions(-) create mode 100644 apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-list.component.ts create mode 100644 apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-map.component.ts 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 00000000000..efca10174e4 --- /dev/null +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-list.component.ts @@ -0,0 +1,258 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, inject, model, output } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { settingSignal, SettingsService, Space } from '@placeos/common'; +import { IconComponent, LevelPipe, TranslatePipe } from '@placeos/components'; +import { EventFormService } from '@placeos/events'; + +const FAV_SPACE_KEY = 'favourite_rooms'; + +@Component({ + selector: 'meeting-flow-space-list', + template: ` + @let loadn = loading | async; + @let spaces = available_spaces | async; + @let alerts = room_alerts | async; + + @if (!loadn) { + @if (spaces?.length) { +
    + @for (space of spaces; track space.id) { +
  • +
    + +
    + + @if (space.approval) { +
    + {{ 'COMMON.APPROVAL_REQUIRED' | translate }} +
    + } +
  • + } +
+ } @else { +
+

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

+
+ } + } @else { +
+ +

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

+
+ } + `, + styles: [``], + imports: [ + AsyncPipe, + TranslatePipe, + MatProgressSpinnerModule, + LevelPipe, + IconComponent, + MatRippleModule, + ], +}) +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 favourites = settingSignal( + FAV_SPACE_KEY, + [], + true, + ); + + public toggleFavourite(space: Space) { + const existing = this.favourites(); + console.log('Toggle Favourites:', space, existing); + if (existing.find((id) => space.id === id)) { + this._settings.saveUserSetting( + FAV_SPACE_KEY, + existing.filter((id) => id !== space.id), + ); + } else { + this._settings.saveUserSetting(FAV_SPACE_KEY, [ + ...existing, + space.id, + ]); + } + } +} 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 00000000000..b9f7b1830c4 --- /dev/null +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-map.component.ts @@ -0,0 +1,170 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, inject, input, OnInit, output } from '@angular/core'; +import { + AsyncHandler, + BuildingLevel, + OrganisationService, + SettingsService, + Space, +} from '@placeos/common'; +import { InteractiveMapComponent, TranslatePipe } from '@placeos/components'; +import { EventFormService } from '@placeos/events'; +import { DEFAULT_COLOURS } from '@placeos/explore'; +import { NewSpaceLocationPinComponent } from 'libs/events/src/lib/new-space-select-modal/new-space-location-pin.component'; +import { BehaviorSubject, combineLatest, debounceTime, map, tap } from 'rxjs'; + +@Component({ + selector: 'meeting-flow-space-map', + template: ` +
+ +
+ `, + styles: [``], + imports: [AsyncPipe, InteractiveMapComponent, TranslatePipe], +}) +export class MeetingFlowSpaceMapComponent + extends AsyncHandler + implements OnInit +{ + 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 zoom = 1; + public center = { x: 0.5, y: 0.5 }; + public coordinates = undefined; + + private _seletedSpace = (s) => () => { + this.space_selected.emit(s); + this._change.next(Date.now()); + }; + public level: BuildingLevel = null; + private _change = new BehaviorSubject(0); + + public get map_url() { + return this.level?.map_id || ''; + } + + public readonly levels = combineLatest([ + this._org.active_region, + this._org.active_building, + ]).pipe( + map(([region, bld]) => { + const level_list = this.use_region + ? this._org.levelsForRegion(region) + : this._org.levelsForBuilding(bld); + const viewable_levels = level_list.filter( + (lvl) => !lvl.tags.includes('parking'), + ); + if (!this.level && viewable_levels.length) { + this.level = viewable_levels[0]; + } + return viewable_levels.sort( + (a, b) => + a.parent_id.localeCompare(b.parent_id) || + (a.display_name || '').localeCompare(b.display_name || ''), + ); + }), + tap((l) => console.log('Levels:', l)), + ); + + public readonly setOptions = (o) => this._event_form.setOptions(o); + + public readonly features = combineLatest([ + this._event_form.available_spaces, + this._change, + ]).pipe( + debounceTime(300), + map(([l]) => + l.map((space) => ({ + location: space.map_id, + content: NewSpaceLocationPinComponent, + data: { + ...space, + active: this.active() === space.id, + selected: this.selected_spaces().includes(space.id), + }, + })), + ), + ); + + public readonly actions = this._event_form.available_spaces.pipe( + map((l) => + l.map((space) => ({ + id: space.map_id, + action: ['touchend', 'mouseup'], + callback: this._seletedSpace(space), + })), + ), + ); + + public readonly styles = combineLatest([ + this._event_form.spaces$, + this._event_form.available_spaces, + ]).pipe( + map(([spaces, free_spaces]) => + spaces.reduce((styles, space) => { + const colours = this._settings.get('app.explore.colors') || {}; + const status = free_spaces.find((_) => _.id === space.id) + ? 'free' + : 'busy'; + styles[`#${space.map_id || space.id}`] = { + fill: + colours[`space-${status}`] || + colours[`${status}`] || + DEFAULT_COLOURS[`${status}`], + }; + return styles; + }, {}), + ), + ); + + public get use_region() { + return !!this._settings.get('app.use_region'); + } + + public ngOnInit() { + this.subscription( + 'levels_update', + this._event_form.options$.subscribe(({ zones }) => { + const level = this._org.levelWithID(zones); + if (level) this.level = level; + }), + ); + } + + public setLevel(level: BuildingLevel) { + this.setOptions({ zone_ids: [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 = { latitude, longitude }; + } + this.level = level; + } + + public setZoom(new_zoom: number) { + this.zoom = Math.max(0.5, Math.min(10, new_zoom)); + } + + public resetMap() { + this.zoom = 1; + this.center = { 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 index d58ffcb15b5..80e5dca0475 100644 --- 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 @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, inject, output, signal } from '@angular/core'; +import { Component, inject, OnInit, output, signal } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -9,6 +9,7 @@ import { OrganisationService, settingSignal, SettingsService, + Space, unique, } from '@placeos/common'; import { @@ -25,8 +26,8 @@ import { TimeFieldComponent, } from '@placeos/form-fields'; import { combineLatest, map } from 'rxjs'; - -const FAV_SPACE_KEY = 'favourite_rooms'; +import { MeetingFlowSpaceListComponent } from './meeting-flow-space-list.component'; +import { MeetingFlowSpaceMapComponent } from './meeting-flow-space-map.component'; @Component({ selector: 'meeting-flow-space-select', @@ -182,321 +183,107 @@ const FAV_SPACE_KEY = 'favourite_rooms';
-
- @let feature_list = (options | async)?.features || []; - @let zones = (options | async)?.features || []; - @let capacity = (options | async)?.capacity || []; - @let event = form().getRawValue(); - @let zone = (zones | level) || (zones | building); - @let location = zone?.display_name || zone?.name || ''; - @if (feature_list.length > 1) { +
+
+ @let feature_list = + (options | async)?.features || []; + @let zones = (options | async)?.features || []; + @let capacity = (options | async)?.capacity || []; + @let event = form().getRawValue(); + @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 }} + } +
+
+ {{ + 'CALENDAR_EVENT.SPACE_SELECT_SIZE_X' + | translate + : { + count: capacity || 2, + } + }} +
+ @for (feat of feature_list; track feat) { +
+

{{ feat }}

+ +
+ } +
+
+ - } - @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 }} - } -
-
- {{ - 'CALENDAR_EVENT.SPACE_SELECT_SIZE_X' - | translate - : { - count: capacity || 2, - } - }}
- @for (feat of feature_list; track feat) { -
-

{{ feat }}

- -
- }
- @if (!(loading | async)) { - @if ((available_spaces | async)?.length) { -
    - @for ( - space of available_spaces | async; - track space - ) { -
  • - - - @if (space.approval) { -
    - {{ - 'COMMON.APPROVAL_REQUIRED' - | translate - }} -
    - } -
  • - } -
- } @else { -
-

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

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

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

-
+ }
@@ -529,8 +316,9 @@ const FAV_SPACE_KEY = 'favourite_rooms'; align-items: center; padding: 0 1rem; min-height: 2rem; + height: 2rem; font-size: 0.875rem; - border: 1px solid rgba(0, 0, 0, 0.2); + border: 1px solid var(--base-300); border-radius: 1.25rem; margin: 0.25rem; max-width: 100%; @@ -561,9 +349,11 @@ const FAV_SPACE_KEY = 'favourite_rooms'; TranslatePipe, BuildingPipe, LevelPipe, + MeetingFlowSpaceListComponent, + MeetingFlowSpaceMapComponent, ], }) -export class MeetingFlowSpaceSelectComponent { +export class MeetingFlowSpaceSelectComponent implements OnInit { private _event_form = inject(EventFormService); private _org = inject(OrganisationService); private _settings = inject(SettingsService); @@ -584,6 +374,8 @@ export class MeetingFlowSpaceSelectComponent { [], ); public readonly form = signal(this._event_form.form); + public readonly selected = signal([]); + public readonly view = signal<'map' | 'list'>('list'); public readonly options = this._event_form.options$; public readonly filters = this._event_form.filters$; @@ -599,15 +391,8 @@ export class MeetingFlowSpaceSelectComponent { public readonly setFilters = (f) => this._event_form.setFilters(f); 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 active = signal(''); - public readonly selected = signal(''); - public readonly favourites = settingSignal( - FAV_SPACE_KEY, - [], - true, - ); public readonly levels = combineLatest([ this._org.active_region, @@ -637,16 +422,20 @@ export class MeetingFlowSpaceSelectComponent { ), ); - public field(name: string) { - return this.form()?.getRawValue()?.[name]; - } - public get timezone() { return this._settings.get('app.events.use_building_timezone') ? this._org.building.timezone : ''; } + public ngOnInit() { + this.selected.set((this.field('resources') || []).map(({ id }) => id)); + } + + public field(name: string) { + return this.form()?.getRawValue()?.[name]; + } + public async toggleFeature(feat: string, state: boolean) { const { features } = this._event_form.filters; const new_list = (features || []).filter((_) => feat !== _); @@ -654,7 +443,17 @@ export class MeetingFlowSpaceSelectComponent { this._event_form.setFilters({ features: new_list }); } - public isFavourite(space_id: string) { - return this.favourites().includes(space_id); + public toggleSpace(space: Space) { + const resources = this.field('resources') || []; + if (this._settings.get('app.events.allow_multiple_spaces')) { + const new_resources = resources.find(({ id }) => id === space.id) + ? resources.filter(({ id }) => id !== space.id) + : [...resources, space]; + this.form().patchValue({ resources: new_resources }); + this.selected.set(new_resources.map(({ id }) => id)); + } else { + this.form().patchValue({ resources: [space] }); + this.selected.set([space.id]); + } } } 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 index 495155ce03d..1f63d5f02e9 100644 --- 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 @@ -12,7 +12,7 @@ import { MeetingFlowSpaceSelectComponent } from './meeting-flow-space-select.com template: `
this.get(key) ?? old); - } + this._updateSignals(); } public get theme() { @@ -254,6 +252,7 @@ export class SettingsService extends AsyncHandler { private async _savePendingChanges() { const user = currentUser(); if (!user?.id || !Object.keys(this._pending_settings).length) return; + this._updateSignals(); await lastValueFrom( updateMetadata(user.id, { name: 'settings', @@ -309,4 +308,10 @@ export class SettingsService extends AsyncHandler { : false; this.setTheme(os_dark ? 'dark' : ''); } + + private _updateSignals() { + for (const key in _setting_signals) { + _setting_signals[key].update((old) => this.get(key) ?? old); + } + } } From a4d439bc3999ddbd25cceb73658a8ebb448b8b75 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Tue, 4 Nov 2025 12:43:22 +1100 Subject: [PATCH 004/392] chore(workplace): update mobile menu --- .../app/components/footer-menu.component.ts | 132 +++++++----------- .../app/landing-new/landing-new.component.ts | 14 +- shared/assets/locale/en-AU.json | 1 + 3 files changed, 59 insertions(+), 88 deletions(-) diff --git a/apps/workplace/src/app/components/footer-menu.component.ts b/apps/workplace/src/app/components/footer-menu.component.ts index e53ef12c636..4c576dfa702 100644 --- a/apps/workplace/src/app/components/footer-menu.component.ts +++ b/apps/workplace/src/app/components/footer-menu.component.ts @@ -1,4 +1,6 @@ -import { Component, inject, OnInit, signal } from '@angular/core'; +import { Component, inject, model, OnInit, signal } from '@angular/core'; +import { MatBottomSheet } from '@angular/material/bottom-sheet'; +import { MatRippleModule } from '@angular/material/core'; import { RouterModule } from '@angular/router'; import { AsyncHandler, @@ -6,19 +8,19 @@ import { SettingsService, } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; +import { ChatViewComponent } from '../landing-new/chat-view.component'; @Component({ selector: 'footer-menu', template: ` @if (show_book_items() && features().length > 1) {
-
@if (features().includes('spaces')) { -
meeting_room - meeting_room - meeting_room -
-
+
{{ 'APP.WORKPLACE.MENU_ROOMS' | translate }}
@@ -50,20 +44,10 @@ import { IconComponent, TranslatePipe } from '@placeos/components'; name="footer-nav-desks" [routerLink]="['/book', 'desks']" routerLinkActive="active" - class="m-2 flex w-1/3 flex-col items-center justify-center space-y-2" + class="flex flex-col items-center justify-center space-y-4 rounded-xl bg-base-200 px-4 py-8" > -
- desk - desk -
-
+ desk +
{{ 'APP.WORKPLACE.MENU_DESKS' | translate }}
@@ -74,20 +58,12 @@ import { IconComponent, TranslatePipe } from '@placeos/components'; name="footer-nav-parking" [routerLink]="['/book', 'parking']" routerLinkActive="active" - class="m-2 flex w-1/3 flex-col items-center justify-center space-y-2 text-base" + class="flex flex-col items-center justify-center space-y-4 rounded-xl bg-base-200 px-4 py-8" > -
directions_car - directions_car - directions_car -
-
+
{{ 'APP.WORKPLACE.MENU_PARKING' | translate }}
@@ -98,20 +74,12 @@ import { IconComponent, TranslatePipe } from '@placeos/components'; name="footer-nav-visitor-invite" [routerLink]="['/book', 'visitor']" routerLinkActive="active" - class="m-2 flex w-1/3 flex-col items-center justify-center space-y-2" + class="flex flex-col items-center justify-center space-y-4 rounded-xl bg-base-200 px-4 py-8" > -
person_add - person_add - person_add -
-
+
{{ 'APP.WORKPLACE.MENU_VISITORS' | translate }}
@@ -122,20 +90,10 @@ import { IconComponent, TranslatePipe } from '@placeos/components'; name="footer-nav-my-day" [routerLink]="['/your-bookings']" routerLinkActive="active" - class="m-2 flex w-1/3 flex-col items-center justify-center space-y-2 text-base" + class="flex flex-col items-center justify-center space-y-4 rounded-xl bg-base-200 px-4 py-8" > -
- today - today -
-
+ today +
{{ 'APP.WORKPLACE.MENU_SCHEDULE' | translate }}
@@ -146,24 +104,27 @@ import { IconComponent, TranslatePipe } from '@placeos/components'; name="footer-nav-my-day" [routerLink]="['/group-events']" routerLinkActive="active" - class="m-2 flex w-1/3 flex-col items-center justify-center space-y-2 text-base" + class="flex flex-col items-center justify-center space-y-4 rounded-xl bg-base-200 px-4 py-8" > -
local_activity - local_activity - local_activity -
-
+
{{ 'APP.WORKPLACE.MENU_EVENTS' | translate }}
} +
} @@ -193,7 +154,10 @@ import { IconComponent, TranslatePipe } from '@placeos/components';
@if (!hide_nav()) { - + }
diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index d65b026bee0..b98be63ad84 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -846,6 +846,7 @@ "MENU_CONTROL": "Room Control", "MENU_EXPLORE": "Spaces", "MENU_EVENTS": "Events", + "MENU_AI": "Ben AI", "MENU_DEALS": "Deals & Offers", "GLOBAL_SEARCH": "Search for people or spaces...", "GLOBAL_SEARCH_EMPTY": "No matches found.", From 903740da6657483ca21d27cfa13decb6c17a40be Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Tue, 4 Nov 2025 21:46:15 +1100 Subject: [PATCH 005/392] chore(workplace): start adding the new schedule view logic --- .../app/landing-new/landing-new.component.ts | 15 +- .../schedule/schedule-day-view.component.ts | 12 + .../schedule/schedule-filters.component.ts | 23 +- .../schedule/schedule-list-view.component.ts | 92 ++++ .../schedule/schedule-sidebar.component.ts | 317 +++++--------- .../app/schedule/schedule-state.service.ts | 166 ++++++++ .../app/schedule/schedule-topbar.component.ts | 101 +++++ .../schedule/schedule-week-view.component.ts | 145 +++++++ .../src/app/schedule/schedule.component.ts | 393 +++--------------- .../src/lib/booking-card.component.ts | 2 +- .../src/lib/settings-toggle.component.ts | 2 +- libs/events/src/lib/event-card.component.ts | 2 +- shared/styles/application.scss | 2 +- 13 files changed, 696 insertions(+), 576 deletions(-) create mode 100644 apps/workplace/src/app/schedule/schedule-day-view.component.ts create mode 100644 apps/workplace/src/app/schedule/schedule-list-view.component.ts create mode 100644 apps/workplace/src/app/schedule/schedule-topbar.component.ts create mode 100644 apps/workplace/src/app/schedule/schedule-week-view.component.ts diff --git a/apps/workplace/src/app/landing-new/landing-new.component.ts b/apps/workplace/src/app/landing-new/landing-new.component.ts index 1f2b5c76944..19ed23ef31b 100644 --- a/apps/workplace/src/app/landing-new/landing-new.component.ts +++ b/apps/workplace/src/app/landing-new/landing-new.component.ts @@ -224,9 +224,18 @@ interface FavoriteEntry { Quick Actions
-

Reserve

([]); +} diff --git a/apps/workplace/src/app/schedule/schedule-filters.component.ts b/apps/workplace/src/app/schedule/schedule-filters.component.ts index 03c028a4b25..79b46c5670f 100644 --- a/apps/workplace/src/app/schedule/schedule-filters.component.ts +++ b/apps/workplace/src/app/schedule/schedule-filters.component.ts @@ -10,23 +10,16 @@ import { ScheduleStateService } from './schedule-state.service'; @Component({ selector: 'schedule-filters', template: ` - `, 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 index 43a21c8312f..862f5853d09 100644 --- a/apps/workplace/src/app/landing-new/landing-favourites-new.component.ts +++ b/apps/workplace/src/app/landing-new/landing-favourites-new.component.ts @@ -8,7 +8,7 @@ import { ParkingSpace, } from '@placeos/bookings'; import { Desk, settingSignal, Space } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; +import { IconComponent, LevelPipe, TranslatePipe } from '@placeos/components'; import { ExploreSpacesService } from '@placeos/explore'; export interface FavoriteEntry { @@ -27,8 +27,8 @@ export interface FavoriteEntry { class="flex flex-col rounded-lg border border-base-300 bg-base-100 p-4" >
-

Favourites

-
{{ favourites().length }} favourites
+

{{ 'COMMON.FAVOURITES' | translate }}

+
{{ 'APP.WORKPLACE.FAVOURITES_COUNT' | translate: { count: favourites().length } : favourites().length }}
@if (favourites().length) {
`, providers: [ExploreSpacesService], - imports: [CommonModule, IconComponent, TranslatePipe], + imports: [CommonModule, IconComponent, TranslatePipe, LevelPipe], }) export class LandingFavouritesNewComponent { private _explore = inject(ExploreSpacesService); diff --git a/apps/workplace/src/app/landing-new/landing-new.component.ts b/apps/workplace/src/app/landing-new/landing-new.component.ts index cdb559d2023..b7fa3b907e2 100644 --- a/apps/workplace/src/app/landing-new/landing-new.component.ts +++ b/apps/workplace/src/app/landing-new/landing-new.component.ts @@ -53,7 +53,7 @@ import { LandingUpcomingBookingComponent } from './landing-upcoming-booking.comp icon matRipple class="absolute left-2 top-60 h-14 w-14 bg-secondary text-secondary-content shadow" - matTooltip="Virtual Concierge" + [matTooltip]="'APP.WORKPLACE.VIRTUAL_CONCIERGE' | translate" matTooltipPosition="right" (click)="openConciergeChat()" > diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index b98be63ad84..839fc712380 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -625,6 +625,29 @@ "RULES_HIDDEN_N": "You are not allowed to book some of the selected {{ type }}s for the selected time or duration" }, "CALENDAR_EVENT": { + "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", + "RECURRENCE_WEEKLY": "Weekly", + "RECURRENCE_MONTHLY": "Monthly", + "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", "CATERING_ORDER_AT_DATE": "Order for {{ date }} at {{ time }}", "CATERING_ORDER_AT": "Order at {{ time }}", @@ -880,6 +903,8 @@ "COLLEAGUE_SEARCH_LOADING": "Searching for users...", "COLLEAGUE_ADDED": "Successfully added \"{{ name }}\" to contacts", "COLLEAGUE_REMOVED": "Successfully removed \"{{ name }}\" from contacts", + "ADD_COLLEAGUES": "Add Colleagues", + "VIRTUAL_CONCIERGE": "Virtual Concierge", "AVAILABLE_LIST_HEADER": "Available Now", "AVAILABLE_LIST_SPACES": "Spaces", "AVAILABLE_LIST_SPACES_EMPTY": "No spaces available to book at the current time", From cabf624c374ccc182953584e9a1baeeebde514f0 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 6 Nov 2025 17:11:35 +1100 Subject: [PATCH 013/392] chore(workplace): add auto-assign desk modal (PPT-2283) --- .../auto-assigned-desk-modal.component.ts | 390 ++++++++++++++++++ .../desk-flow/desk-flow-form.component.ts | 39 +- .../meeting-flow-new.component.ts | 2 +- .../meeting-flow-space-map.component.ts | 4 +- .../landing-quick-actions.component.ts | 34 +- .../src/app/schedule/schedule.component.ts | 2 - .../src/lib/map-renderer.component.ts | 8 +- shared/assets/locale/en-AU.json | 8 + 8 files changed, 476 insertions(+), 11 deletions(-) create mode 100644 apps/workplace/src/app/book/desk-flow/auto-assigned-desk-modal.component.ts 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 00000000000..2c4c196019c --- /dev/null +++ b/apps/workplace/src/app/book/desk-flow/auto-assigned-desk-modal.component.ts @@ -0,0 +1,390 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + inject, + model, + OnInit, + signal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { BookingAsset, BookingFormService } from '@placeos/bookings'; +import { + AsyncHandler, + Desk, + firstTruthyValueFrom, + i18n, + nextValueFrom, + notifyError, + OrganisationService, + SettingsService, +} from '@placeos/common'; +import { + IconComponent, + InteractiveMapComponent, + MapPinComponent, + TranslatePipe, +} from '@placeos/components'; +import { isBefore, startOfMinute } from 'date-fns'; + +@Component({ + selector: 'placeos-auto-assigned-desk-modal', + standalone: true, + template: ` +
+ +
+

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

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

+ {{ '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 _dialog_ref = inject(MatDialogRef, { + optional: true, + }); + + public readonly show_close = model(false); + public readonly loading = signal(true); + public readonly assigned_desk = signal(null); + public readonly date = model(undefined); + public readonly duration = model(undefined); + + public zoom = 1.5; + public center = signal({ x: 0.5, y: 0.5 }); + + public async ngOnInit() { + await this.initializeAndAutoAssign(); + } + + private async initializeAndAutoAssign() { + try { + this.loading.set(true); + + // Initialize booking state for desk + this._state.setOptions({ type: 'desk' }); + this._state.form.patchValue({ booking_type: 'desk' }); + + // Set default values + const form = this._state.form; + const now = Date.now(); + const booking_date = + this.date() || + (isBefore(form.value.date || 0, now) + ? startOfMinute(now).valueOf() + : form.value.date); + const booking_duration = + this.duration() || + this._settings.get('app.desks.default_duration') || + 60; + + form.patchValue({ + date: booking_date, + duration: booking_duration, + all_day: false, + }); + + // Get available resources (desks) + const available_desks = await firstTruthyValueFrom( + this._state.resources, + ); + + if (!available_desks?.length) { + notifyError(i18n('BOOKINGS.DESK_LIST_EMPTY')); + this.dismiss(); + return; + } + + // Group desks by level and find level with most available desks + const desks_by_level = available_desks.reduce( + (acc, desk) => { + const level_id = desk.zone?.id || 'unknown'; + if (!acc[level_id]) { + acc[level_id] = []; + } + acc[level_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 + const assigned_desk = level_with_most_desks[1][0]; + + form.patchValue({ + asset_id: assigned_desk.id, + resources: [assigned_desk], + }); + + this.assigned_desk.set(assigned_desk); + + this.loading.set(false); + } catch (error) { + console.error('Error auto-assigning desk:', error); + notifyError(i18n('BOOKINGS.DESK_LIST_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 = toSignal(this._state.resources, { + initialValue: [], + }); + + // 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 []; + + console.log('Assigned:', assigned); + + 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(true); + try { + if ((await nextValueFrom(this._state.options))?.group) { + await this._state.postFormForGroup(); + } else { + await this._state.postForm(); + } + this._dialog_ref?.close(true); + } catch (e) { + this.loading.set(false); + notifyError( + typeof e === 'string' + ? e + : i18n(`BOOKINGS.DESK_AVAILABLE_ERROR`), + ); + } + }; + + 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 b3433781b8b..ffcdf8c917e 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 { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { MatRippleModule } from '@angular/material/core'; import { Router } from '@angular/router'; import { BookingFormService } from '@placeos/bookings'; @@ -20,6 +21,7 @@ import { lastValueFrom } from 'rxjs'; import { first } from 'rxjs/operators'; import { NewDeskFlowConfirmComponent } from './desk-flow-confirm.component'; import { NewDeskFormDetailsComponent } from './desk-form-details.component'; +import { AutoAssignedDeskModalComponent } from './auto-assigned-desk-modal.component'; @Component({ selector: 'desk-flow-form', @@ -52,7 +54,12 @@ import { NewDeskFormDetailsComponent } from './desk-form-details.component';

- @@ -108,6 +115,7 @@ export class NewDeskFlowFormComponent implements OnInit { private _router = inject(Router); private _org = inject(OrganisationService); private _bottom_sheet = inject(MatBottomSheet); + private _dialog = inject(MatDialog); private _settings = inject(SettingsService); public sheet_ref: MatBottomSheetRef; @@ -144,6 +152,35 @@ export class NewDeskFlowFormComponent implements OnInit { }); }; + public readonly autoAssignDesk = () => { + // Get current form values for date and duration + const form_value = this.form.getRawValue(); + const { date, duration } = form_value; + + // 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._state.setView('success'); + this._router.navigate(['/book', 'desks', 'success']); + } + }); + }; + public async ngOnInit() { await firstTruthyValueFrom(this._org.initialised); await lastValueFrom( diff --git a/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-new.component.ts b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-new.component.ts index 98807822ed7..2acb182541a 100644 --- a/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-new.component.ts +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-new.component.ts @@ -7,7 +7,7 @@ import { MeetingFlowSuccessComponent } from '../meeting-flow/meeting-flow-succes import { MeetingFlowComponent } from './meeting-flow.component'; @Component({ - selector: 'placeos-book-meeting-flow', + selector: 'book-meeting-flow-new', template: `
@switch (view()) { 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 index b9f7b1830c4..9d28c6ff2d2 100644 --- 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 @@ -7,7 +7,7 @@ import { SettingsService, Space, } from '@placeos/common'; -import { InteractiveMapComponent, TranslatePipe } from '@placeos/components'; +import { InteractiveMapComponent } from '@placeos/components'; import { EventFormService } from '@placeos/events'; import { DEFAULT_COLOURS } from '@placeos/explore'; import { NewSpaceLocationPinComponent } from 'libs/events/src/lib/new-space-select-modal/new-space-location-pin.component'; @@ -29,7 +29,7 @@ import { BehaviorSubject, combineLatest, debounceTime, map, tap } from 'rxjs';
`, styles: [``], - imports: [AsyncPipe, InteractiveMapComponent, TranslatePipe], + imports: [AsyncPipe, InteractiveMapComponent], }) export class MeetingFlowSpaceMapComponent extends AsyncHandler 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 index a7148a91c9c..7b1abe45a1e 100644 --- a/apps/workplace/src/app/landing-new/landing-quick-actions.component.ts +++ b/apps/workplace/src/app/landing-new/landing-quick-actions.component.ts @@ -1,7 +1,9 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { MatRippleModule } from '@angular/material/core'; -import { RouterModule } from '@angular/router'; +import { Router, RouterModule } from '@angular/router'; import { IconComponent } from '@placeos/components'; +import { AutoAssignedDeskModalComponent } from '../book/desk-flow/auto-assigned-desk-modal.component'; @Component({ selector: 'landing-quick-actions', @@ -12,7 +14,12 @@ import { IconComponent } from '@placeos/components';

Quick Actions

- + +
+
+ `, + styles: [``], + imports: [IconComponent], +}) +export class DeskFlowAutoAssignComponent { + private _booking_form = inject(BookingFormService); + private _dialog = inject(MatDialog); + private _router = inject(Router); + + public get form() { + return this._booking_form.form; + } + + public readonly autoAssignDesk = () => { + // Get current form values for date and duration + const form_value = this.form.getRawValue(); + const { date, duration } = form_value; + + // 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'); + this._router.navigate(['/book', 'desks', 'success']); + } + }); + }; +} diff --git a/apps/workplace/src/app/book/desk-flow-new/desk-flow-desk-details.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow-desk-details.component.ts new file mode 100644 index 00000000000..fb665fd6d2d --- /dev/null +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow-desk-details.component.ts @@ -0,0 +1,147 @@ +import { Component, input, model, output } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { BookingAsset } from '@placeos/bookings'; +import { + AuthenticatedImageDirective, + IconComponent, + TranslatePipe, +} from '@placeos/components'; + +@Component({ + selector: 'desk-flow-desk-details', + template: ` + @if (desk()) { +
+ +
+ @if (desk()?.images?.length) { + + } @else { + + } + +
+
+
+

+ {{ desk()?.name || desk()?.id || 'Desk' }} +

+
+ place +

+ {{ + desk()?.zone?.display_name || + desk()?.zone?.name || + 'Unknown Location' + }} +

+
+
+ + @if (desk()?.description) { +
+

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

+

+ {{ desk()?.description }} +

+
+ } + + @if (desk()?.features?.length) { +
+

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

+
+ @for ( + feature of desk()?.features; + track feature + ) { +
+ {{ feature }} +
+ } +
+
+ } + +
+ + +
+
+ } + `, + styles: [``], + imports: [ + TranslatePipe, + IconComponent, + MatRippleModule, + AuthenticatedImageDirective, + ], +}) +export class DeskFlowDeskDetailsComponent { + public readonly desk = input(null); + public readonly active = model(false); + public readonly fav = input(false); + public readonly hide_map = input(false); + + public readonly activeChange = output(); + public readonly toggleFav = output(); + public readonly close = output(); +} 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 00000000000..7ab52148c16 --- /dev/null +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow-details.component.ts @@ -0,0 +1,226 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, computed, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +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 { BookingFormService } from '@placeos/bookings'; +import { + currentUser, + OrganisationService, + settingSignal, + SettingsService, +} from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { + DateFieldComponent, + DurationFieldComponent, + RecurrenceFieldComponent, + TimeFieldComponent, + UserListFieldComponent, + UserSearchFieldComponent, +} from '@placeos/form-fields'; +import { addDays, endOfDay } from 'date-fns'; + +type FormType = 'single' | 'group' | 'other'; + +@Component({ + selector: 'desk-flow-details', + template: ` +
+
+ + + +
+
+ @if (active_form() === 'other') { +
+ + +
+ } +
+ + + + {{ + 'FORM.TITLE_REQUIRED' | translate + }} + +
+ @if (can_recurr()) { +
+ + +
+ } + @if (can_book_lockers()) { +
+ + {{ 'BOOKINGS.DESK_REQUIRE_LOCKER' | translate }} + +
+ } + @if (active_form() === 'group') { +
+ + +
+ } +
+
+ `, + styles: [``], + imports: [ + AsyncPipe, + MatRippleModule, + MatFormFieldModule, + MatInputModule, + MatCheckboxModule, + FormsModule, + ReactiveFormsModule, + TranslatePipe, + IconComponent, + DateFieldComponent, + TimeFieldComponent, + DurationFieldComponent, + RecurrenceFieldComponent, + UserSearchFieldComponent, + UserListFieldComponent, + ], +}) +export class DeskFlowDetailsComponent { + private _booking_form = inject(BookingFormService); + private _org = inject(OrganisationService); + private _settings = inject(SettingsService); + + public readonly active_form = signal('single'); + public readonly form_value = toSignal(this.form.valueChanges, { + initialValue: this.form.value, + }); + public readonly options = this._booking_form.options; + + 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 readonly max_duration = computed( + () => + settingSignal('desks.max_duration')() || + settingSignal('bookings.max_duration', 8 * 60)(), + ); + public readonly can_book_lockers = settingSignal('desks.can_book_lockers'); + public readonly can_recurr = settingSignal('desks.allow_recurrence'); + 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); + + 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: FormType) { + this.active_form.set(form as any); + this._booking_form.setOptions({ group: form === 'group' }); + if (form === 'single') { + this.form.patchValue({ user: currentUser(), attendees: [] }); + } else if (form === 'group') { + this.form.patchValue({ user: currentUser() }); + } else { + this.form.patchValue({ attendees: [] }); + } + } + + public setOptions(options: any) { + this._booking_form.setOptions(options); + } + + public onRecurrenceChange(recurrence: any) { + this.form.patchValue(recurrence); + } +} 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 00000000000..c0746693f4b --- /dev/null +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow-select-list.component.ts @@ -0,0 +1,192 @@ +import { AsyncPipe, CommonModule } from '@angular/common'; +import { Component, inject, model, output } 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'; + +const FAV_DESK_KEY = 'favourite_desks'; + +@Component({ + selector: 'desk-flow-select-list', + template: ` + @let loadn = loading | async; + @let desks = available_items | async; + + @if (!loadn) { + @if (desks?.length) { +
+ {{ + 'COMMON.RESULTS_COUNT' + | translate: { count: desks.length } + }} +
+
    + @for (item of desks; track item.id) { +
  • +
    + +
    + + @if (item.approval) { +
    + {{ 'COMMON.APPROVAL_REQUIRED' | translate }} +
    + } +
  • + } +
+ } @else { +
+

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

+
+ } + } @else { +
+ +

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

+
+ } + `, + styles: [``], + imports: [ + CommonModule, + AsyncPipe, + TranslatePipe, + MatProgressSpinnerModule, + IconComponent, + MatRippleModule, + ], +}) +export class DeskFlowSelectListComponent { + private _booking_form = inject(BookingFormService); + private _settings = inject(SettingsService); + + public readonly selected_items = model([]); + public readonly item_selected = output(); + + public readonly loading = this._booking_form.loading; + public readonly available_items = this._booking_form.available_resources; + + public readonly favourites = settingSignal( + SETTING_KEYS.FAVORITE_DESKS, + [], + true, + ); + + public toggleFavourite(item: Space) { + const existing = this.favourites(); + console.log('Toggle Favourites:', item, existing); + if (existing.find((id) => item.id === id)) { + this._settings.saveUserSetting( + FAV_DESK_KEY, + existing.filter((id) => id !== item.id), + ); + } else { + this._settings.saveUserSetting(FAV_DESK_KEY, [ + ...existing, + item.id, + ]); + } + } +} 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 00000000000..54b5ffc395f --- /dev/null +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow-select-map.component.ts @@ -0,0 +1,167 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, inject, input, OnInit, output } from '@angular/core'; +import { BookingFormService } from '@placeos/bookings'; +import { + AsyncHandler, + BuildingLevel, + OrganisationService, + SettingsService, + Space, +} from '@placeos/common'; +import { InteractiveMapComponent } from '@placeos/components'; +import { DEFAULT_COLOURS } from '@placeos/explore'; +import { NewSpaceLocationPinComponent } from 'libs/events/src/lib/new-space-select-modal/new-space-location-pin.component'; +import { BehaviorSubject, combineLatest, debounceTime, map, tap } from 'rxjs'; + +@Component({ + selector: 'desk-flow-select-map', + template: ` +
+ +
+ `, + styles: [``], + imports: [AsyncPipe, InteractiveMapComponent], +}) +export class DeskFlowSelectMapComponent extends AsyncHandler implements OnInit { + private _booking_form = inject(BookingFormService); + private _org = inject(OrganisationService); + private _settings = inject(SettingsService); + + public readonly selected_items = input([]); + public readonly active = input(undefined); + public readonly is_displayed = input(false); + public readonly item_selected = output(); + + public zoom = 1; + public center = { x: 0.5, y: 0.5 }; + public coordinates = undefined; + + private _seletedItem = (s) => () => { + this.item_selected.emit(s); + this._change.next(Date.now()); + }; + public level: BuildingLevel = null; + private _change = new BehaviorSubject(0); + + public get map_url() { + return this.level?.map_id || ''; + } + + public readonly levels = combineLatest([ + this._org.active_region, + this._org.active_building, + ]).pipe( + map(([region, bld]) => { + const level_list = this.use_region + ? this._org.levelsForRegion(region) + : this._org.levelsForBuilding(bld); + const viewable_levels = level_list.filter( + (lvl) => !lvl.tags.includes('parking'), + ); + if (!this.level && viewable_levels.length) { + this.level = viewable_levels[0]; + } + return viewable_levels.sort( + (a, b) => + a.parent_id.localeCompare(b.parent_id) || + (a.display_name || '').localeCompare(b.display_name || ''), + ); + }), + tap((l) => console.log('Levels:', l)), + ); + + public readonly setOptions = (o) => this._booking_form.setOptions(o); + + public readonly features = combineLatest([ + this._booking_form.available_resources, + this._change, + ]).pipe( + debounceTime(300), + map(([l]) => + l.map((space) => ({ + location: space.map_id, + content: NewSpaceLocationPinComponent, + data: { + ...space, + active: this.active() === space.id, + selected: this.selected_items().includes(space.id), + }, + })), + ), + ); + + public readonly actions = this._booking_form.available_resources.pipe( + map((l) => + l.map((space) => ({ + id: space.map_id, + action: ['touchend', 'mouseup'], + callback: this._seletedItem(space), + })), + ), + ); + + public readonly styles = combineLatest([ + this._booking_form.resources, + this._booking_form.available_resources, + ]).pipe( + map(([spaces, free_spaces]) => + spaces.reduce((styles, space) => { + const colours = this._settings.get('app.explore.colors') || {}; + const status = free_spaces.find((_) => _.id === space.id) + ? 'free' + : 'busy'; + styles[`#${space.map_id || space.id}`] = { + fill: + colours[`space-${status}`] || + colours[`${status}`] || + DEFAULT_COLOURS[`${status}`], + }; + return styles; + }, {}), + ), + ); + + public get use_region() { + return !!this._settings.get('app.use_region'); + } + + public ngOnInit() { + this.subscription( + 'levels_update', + this._booking_form.options.subscribe(({ zone_id }) => { + const level = this._org.levelWithID([zone_id]); + if (level) this.level = level; + }), + ); + } + + public setLevel(level: BuildingLevel) { + this.setOptions({ zone_ids: [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 = { latitude, longitude }; + } + this.level = level; + } + + public setZoom(new_zoom: number) { + this.zoom = Math.max(0.5, Math.min(10, new_zoom)); + } + + public resetMap() { + this.zoom = 1; + this.center = { 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 00000000000..6801fc29904 --- /dev/null +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow-select.component.ts @@ -0,0 +1,673 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +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 { + 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 { combineLatest, map } from 'rxjs'; +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 | async)?.features || []; + @let zones = (options | async)?.zones || []; + @let event = form().getRawValue(); + @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 | async)?.length) { + + + @for (reg of regions | async; track reg) { + + {{ reg.display_name || reg.name }} + + } + + + } + @if (!use_region() && (buildings | async)?.length > 1) { + + + @for (bld of buildings | async; track bld) { + + {{ bld.display_name || bld.name }} + + } + + + } + + + @for (lvl of levels | async; track lvl) { + +
+ @if (use_region) { +
+ {{ + (lvl?.parent_id | building) + ?.display_name + }} + + - + +
+ } +
+ {{ lvl.display_name || lvl.name }} +
+
+
+ } +
+
+ + + {{ + 'COMMON.ALL_DAY' | translate + }} + @if (!field('all_day')) { + +
+ + +
+ } + {{ + 'COMMON.FAVOURITES_ONLY' | translate + }} +

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

+
+ @for (feat of features | async; 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: [ + CommonModule, + MatRippleModule, + IconComponent, + MatFormFieldModule, + MatSelectModule, + DateFieldComponent, + TimeFieldComponent, + DurationFieldComponent, + ReactiveFormsModule, + FormsModule, + SettingsToggleComponent, + TranslatePipe, + BuildingPipe, + LevelPipe, + DeskFlowSelectListComponent, + DeskFlowSelectMapComponent, + ], +}) +export class DeskFlowSelectComponent implements OnInit { + private _booking_form = inject(BookingFormService); + private _org = inject(OrganisationService); + private _settings = inject(SettingsService); + private _spaces = inject(SpacesService); + + public readonly use_24hr = settingSignal('use_24_hour_time', false); + public readonly use_region = settingSignal('use_region', false); + public readonly max_duration = settingSignal( + 'bookings.max_duration', + 8 * 60, + ); + public readonly feature_display = settingSignal>( + 'desks.feature_decriptions', + {}, + ); + public readonly hide_features = settingSignal( + 'desks.hide_features', + [], + ); + public readonly form = signal(this._booking_form.form); + public readonly selected = signal([]); + public readonly view = signal<'map' | 'list'>('list'); + public readonly filters_open = signal(false); + + private readonly form_value = toSignal( + this._booking_form.form.valueChanges, + { + initialValue: this._booking_form.form.value, + }, + ); + + 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 = toSignal(this._booking_form.options); + // public readonly filters = this._booking_form.; + + 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(''); + + public readonly levels = combineLatest([ + this._org.active_region, + this._org.active_building, + ]).pipe( + map(([region, bld]) => { + const level_list = this.use_region + ? this._org.levelsForRegion(region) + : this._org.levelsForBuilding(bld); + const viewable_levels = level_list.filter( + (lvl) => !lvl.tags.includes('parking'), + ); + return viewable_levels.sort( + (a, b) => + a.parent_id.localeCompare(b.parent_id) || + (a.display_name || '').localeCompare(b.display_name || ''), + ); + }), + ); + + public readonly features = combineLatest([ + this._booking_form.resources, + ]).pipe( + map(([resources]) => unique(flatten(resources.map((_) => _.features)))), + ); + + 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() { + this.selected.set((this.field('resources') || []).map(({ id }) => id)); + } + + public field(name: string) { + return this.form()?.getRawValue()?.[name]; + } + + 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 toggleDesk(space: Space) { + const resources = this.field('resources') || []; + 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.form().patchValue({ resources: new_resources }); + this.selected.set(new_resources.map(({ id }) => id)); + } else { + this.form().patchValue({ resources: [space] }); + 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-new/desk-flow.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts new file mode 100644 index 00000000000..7f3bbbad33a --- /dev/null +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts @@ -0,0 +1,106 @@ +import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatRippleModule } from '@angular/material/core'; +import { BookingFormService } from '@placeos/bookings'; +import { AsyncHandler } from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { DeskFlowAutoAssignComponent } from './desk-flow-auto-assign.component'; +import { DeskFlowDetailsComponent } from './desk-flow-details.component'; +import { DeskFlowSelectComponent } from './desk-flow-select.component'; + +@Component({ + selector: 'desk-flow-new', + template: ` +
+
+ +
+
+ info +
+ {{ 'BOOKINGS.DESK_DETAILS_HEADER' | translate }} +
+
+ + +
+
+
+ +
+
+
+ `, + styles: [ + ` + :host { + width: 100%; + height: 100%; + } + + .gradient { + background: linear-gradient( + 105deg, + var(--base-200) 0%, + var(--base-200) 50%, + var(--base-100) 100% + ); + } + `, + ], + imports: [ + IconComponent, + TranslatePipe, + MatRippleModule, + DeskFlowAutoAssignComponent, + DeskFlowDetailsComponent, + DeskFlowSelectComponent, + ], +}) +export class DeskFlowNewComponent extends AsyncHandler implements OnInit { + private _booking_form = inject(BookingFormService); + + public readonly view = signal(0); + + private readonly form_value = toSignal( + this._booking_form.form.valueChanges, + { + initialValue: this._booking_form.form.value, + }, + ); + + 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 ngOnInit() { + this._booking_form.form.patchValue({ booking_type: 'desk' }); + this._booking_form.setOptions({ type: 'desk' }); + } + + public confirmBooking() { + console.log('Booking confirmed'); + } +} 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 ffcdf8c917e..2c5f920257a 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,8 +3,8 @@ import { MatBottomSheet, MatBottomSheetRef, } from '@angular/material/bottom-sheet'; -import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; import { BookingFormService } from '@placeos/bookings'; import { @@ -15,64 +15,18 @@ import { OrganisationService, SettingsService, } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; +import { TranslatePipe } from '@placeos/components'; import { isBefore, startOfMinute } from 'date-fns'; import { lastValueFrom } from 'rxjs'; import { first } from 'rxjs/operators'; import { NewDeskFlowConfirmComponent } from './desk-flow-confirm.component'; import { NewDeskFormDetailsComponent } from './desk-form-details.component'; -import { AutoAssignedDeskModalComponent } from './auto-assigned-desk-modal.component'; @Component({ selector: 'desk-flow-form', styles: [], template: `
-
-
-
-
-
Quick Action
-
-
- auto_awesome -
Smart Selection
-
-
-
-

Auto-Assign

-

- We'll always try to book you in your home - neighbourhood—if it's full, we'll find you a spot - nearby. -

-
-
- - -
-
@@ -103,12 +57,7 @@ import { AutoAssignedDeskModalComponent } from './auto-assigned-desk-modal.compo
`, - imports: [ - TranslatePipe, - NewDeskFormDetailsComponent, - IconComponent, - MatRippleModule, - ], + imports: [TranslatePipe, NewDeskFormDetailsComponent, MatRippleModule], }) export class NewDeskFlowFormComponent implements OnInit { private _state = inject(BookingFormService); @@ -152,35 +101,6 @@ export class NewDeskFlowFormComponent implements OnInit { }); }; - public readonly autoAssignDesk = () => { - // Get current form values for date and duration - const form_value = this.form.getRawValue(); - const { date, duration } = form_value; - - // 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._state.setView('success'); - this._router.navigate(['/book', 'desks', 'success']); - } - }); - }; - public async ngOnInit() { await firstTruthyValueFrom(this._org.initialised); await lastValueFrom( diff --git a/libs/bookings/src/lib/booking-form.service.ts b/libs/bookings/src/lib/booking-form.service.ts index 4b705bff2b8..2ee99064d3e 100644 --- a/libs/bookings/src/lib/booking-form.service.ts +++ b/libs/bookings/src/lib/booking-form.service.ts @@ -1,4 +1,4 @@ -import { inject, Injectable, signal } from '@angular/core'; +import { inject, Injectable, signal, WritableSignal } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Event, NavigationEnd, Router } from '@angular/router'; import { @@ -15,6 +15,8 @@ import { notifyWarn, OrganisationService, rulesForResource, + SETTING_KEYS, + settingSignal, SettingsService, unique, User, @@ -124,6 +126,18 @@ export class BookingFormService extends AsyncHandler { private _booking = new BehaviorSubject(null); private _resource_use: Record = {}; private _loading = new BehaviorSubject(''); + private _favourites: Record> = { + ' ': settingSignal('favorites', [], true), + room: settingSignal(SETTING_KEYS.FAVOURITE_ROOMS, [], true), + 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), + }; public last_success: Booking = new Booking( JSON.parse( @@ -224,6 +238,7 @@ export class BookingFormService extends AsyncHandler { date = startOfDay(date).valueOf(); duration = 24 * 60 - 1; } + const favourites = this._favourites[options.type]?.() || []; return bookedResourceList({ period_start: getUnixTime(date), period_end: getUnixTime(addMinutes(date, duration)), @@ -255,6 +270,8 @@ export class BookingFormService extends AsyncHandler { ).hidden; return ( !is_restricted && + (!options.show_fav || + favourites.includes(asset.id)) && (!asset.groups?.length || asset.groups.some((grp) => currentUser().groups.includes(grp), diff --git a/libs/common/src/lib/constants.ts b/libs/common/src/lib/constants.ts index 8471f49d4d4..fe836186039 100644 --- a/libs/common/src/lib/constants.ts +++ b/libs/common/src/lib/constants.ts @@ -15,6 +15,13 @@ export const BOOKING_TYPE_COLORS: Record = { 'group-event': ['#FCD34D', '#D97706'], }; +export const SETTING_KEYS: Record = { + FAVORITE_ROOMS: 'favourite_rooms', + FAVORITE_DESKS: 'favourite_desks', + FAVORITE_PARKING_SPACES: 'favourite_parking', + FAVORITE_LOCKERS: 'favourite_lockers', +}; + /////////////////////////////////////////////////////////////// ////////////////////// Workplace App ////////////////////// /////////////////////////////////////////////////////////////// diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 2b9fcd15158..8f49f1c4118 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -90,6 +90,8 @@ "ACCESSIBLE_ONLY": "Accessible Only", "LANGUAGE": "Language", "LANGUAGE_SELECT": "Select a language", + "REGION": "Region", + "BUILDING": "Building", "ALL_DAY": "All Day", "CHECK_IN": "Check-in", "CHECK_OUT": "Check-out", @@ -101,6 +103,11 @@ "LOAD_MORE": "Load more", "PREVIEW": "Preview", "DETAILS": "Details", + "DESCRIPTION": "Description", + "DESCRIPTION_EMPTY": "No Description", + "DESCRIPTION_REQUIRED": "A description is required", + "ADD_TO": "Add to Booking", + "REMOVE_FROM": "Remove from Booking", "VIEW": "View", "VIEW_ALL": "View All", "VIEW_DETAILS": "View Details", @@ -121,9 +128,6 @@ "STATE_IN_PROGRESS": "In Progress", "STATE_ACTIVE": "Active", "LOCATION": "Location", - "DESCRIPTION": "Description", - "DESCRIPTION_EMPTY": "No Description", - "DESCRIPTION_REQUIRED": "A description is required", "ZONE": "Zone", "ZONE_ANY": "Any Zone", "ZONE_REQUIRED": "A valid zone is required", @@ -183,8 +187,6 @@ "SUPPORT_NO_MAILER": "Mailing system not configured for application.", "SUPPORT_SUCCESS": "Successfully submitted support ticket", "SUPPORT_MAIL_HEADER": "Support Ticket from Workplace Application", - "REMOVE_FROM": "Remove from booking", - "ADD_TO": "Add to booking", "TAGS": "Tags", "REGION_ANY": "Any Region", "REGION_ALL": "All Regions", @@ -340,6 +342,7 @@ "DATE_END": "End Date", "DATE_EMPTY": "No date selected", "DATE_ERROR": "Date and time must be in the future", + "DATE_REQUIRED": "A valid date is required", "TIME_START": "Start time", "TIME_END": "End time", "USER_SEARCH": "Search for user...", @@ -546,6 +549,8 @@ "DESK_REQUIRE_LOCKER": "Require locker", "DESK_GROUP_MEMBERS": "Group Members", "DESK_GROUP_INFO": "Desks nearby to the selected desk will booked for group members", + "DESK_DETAILS_HEADER": "Booking Details", + "DESK_SELECT_HEADER": "Select Desk", "DESK_ASSETS_REQUESTED": "Requested assets", "DESK_LOCKER_REQUESTED": "Requested locker", "DESK_AVAILABLE_ERROR": "Desk is unavailable at this time.", @@ -616,6 +621,8 @@ "SPACE_ADD_TO": "Add this space", "INDUCTED": "Inducted", "PASS_NUMBER": "Pass Number", + "FACILITIES": "Features", + "DESK_SELECT_EMPTY": "No available desks for selected time and/or filters", "RECURRING_TOOLTIP": "This booking is part of a recurring series", "ASSETS_ADD_HEADER": "Add Assets", @@ -724,8 +731,6 @@ "SPACE_ADD_TO": "Add this room", "SPACE_LIST_INFO": "Select a room to view its details", "SPACE_LOCATION": "Location", - "SPACE_REGION_ANY": "Any region", - "SPACE_LEVEL_ANY": "Any level", "SPACE_SELECT_EMPTY": "No available spaces for selected time and/or filters", "SPACE_SELECT_LOADING": "Finding available spaces...", "SPACE_BOOKING_RULES_HIDDEN_1": "The selected room is not available for the selected time or duration", From f59e4f957e6fe44c2452bf7a04d6b04b1c0ff3cb Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 10 Nov 2025 11:36:21 +1100 Subject: [PATCH 015/392] chore(workplace): desk flow tweaks --- .../desk-flow-auto-assign.component.ts | 4 +++- .../desk-flow-details.component.ts | 6 ------ .../desk-flow-select-list.component.ts | 2 +- .../desk-flow-select.component.ts | 2 +- .../book/desk-flow-new/desk-flow.component.ts | 21 +++++++++++++++++-- .../meeting-flow-space-list.component.ts | 18 +++++++++------- libs/bookings/src/lib/booking-form.service.ts | 2 +- libs/common/src/lib/constants.ts | 2 +- 8 files changed, 37 insertions(+), 20 deletions(-) diff --git a/apps/workplace/src/app/book/desk-flow-new/desk-flow-auto-assign.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow-auto-assign.component.ts index df89fdfdc68..6a43bdde50d 100644 --- a/apps/workplace/src/app/book/desk-flow-new/desk-flow-auto-assign.component.ts +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow-auto-assign.component.ts @@ -32,7 +32,9 @@ import { AutoAssignedDeskModalComponent } from '../desk-flow/auto-assigned-desk- it's full, we'll find you a spot nearby.

-
+
    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 index 6801fc29904..5fbcd83d984 100644 --- 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 @@ -36,7 +36,7 @@ import { DeskFlowSelectMapComponent } from './desk-flow-select-map.component'; template: `
    desk 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 index 7f3bbbad33a..50a127873b1 100644 --- 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 @@ -2,7 +2,12 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { MatRippleModule } from '@angular/material/core'; import { BookingFormService } from '@placeos/bookings'; -import { AsyncHandler } from '@placeos/common'; +import { + AsyncHandler, + getInvalidFields, + i18n, + notifyError, +} from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { DeskFlowAutoAssignComponent } from './desk-flow-auto-assign.component'; import { DeskFlowDetailsComponent } from './desk-flow-details.component'; @@ -11,7 +16,9 @@ import { DeskFlowSelectComponent } from './desk-flow-select.component'; @Component({ selector: 'desk-flow-new', template: ` -
    +
    @@ -101,6 +108,16 @@ export class DeskFlowNewComponent extends AsyncHandler implements OnInit { } public confirmBooking() { + this._booking_form.form.markAllAsTouched(); + if (!this._booking_form.form.valid) { + return notifyError( + i18n('FORM.INVALID_FIELDS', { + field_list: getInvalidFields(this._booking_form.form) + .join(', ') + .replace('asset_id', i18n('RESOURCE.DESK')), + }), + ); + } console.log('Booking confirmed'); } } 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 index 30ce31b5954..37126d4fd3e 100644 --- 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 @@ -2,12 +2,15 @@ import { AsyncPipe } from '@angular/common'; import { Component, inject, model, output } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { settingSignal, SettingsService, Space } from '@placeos/common'; +import { + SETTING_KEYS, + settingSignal, + SettingsService, + Space, +} from '@placeos/common'; import { IconComponent, LevelPipe, TranslatePipe } from '@placeos/components'; import { EventFormService } from '@placeos/events'; -const FAV_SPACE_KEY = 'favourite_rooms'; - @Component({ selector: 'meeting-flow-space-list', template: ` @@ -120,7 +123,8 @@ const FAV_SPACE_KEY = 'favourite_rooms'; {{ space.display_name || space.name || - ('CALENDAR_EVENT.MEETING_SPACE_DEFAULT' | translate) + ('CALENDAR_EVENT.MEETING_SPACE_DEFAULT' + | translate) }}
    ( - FAV_SPACE_KEY, + SETTING_KEYS.FAVORITE_ROOMS, [], true, ); @@ -245,11 +249,11 @@ export class MeetingFlowSpaceListComponent { console.log('Toggle Favourites:', space, existing); if (existing.find((id) => space.id === id)) { this._settings.saveUserSetting( - FAV_SPACE_KEY, + SETTING_KEYS.FAVORITE_ROOMS, existing.filter((id) => id !== space.id), ); } else { - this._settings.saveUserSetting(FAV_SPACE_KEY, [ + this._settings.saveUserSetting(SETTING_KEYS.FAVORITE_ROOMS, [ ...existing, space.id, ]); diff --git a/libs/bookings/src/lib/booking-form.service.ts b/libs/bookings/src/lib/booking-form.service.ts index 2ee99064d3e..a14ac61d132 100644 --- a/libs/bookings/src/lib/booking-form.service.ts +++ b/libs/bookings/src/lib/booking-form.service.ts @@ -128,7 +128,7 @@ export class BookingFormService extends AsyncHandler { private _loading = new BehaviorSubject(''); private _favourites: Record> = { ' ': settingSignal('favorites', [], true), - room: settingSignal(SETTING_KEYS.FAVOURITE_ROOMS, [], true), + room: settingSignal(SETTING_KEYS.FAVORITE_ROOMS, [], true), desk: settingSignal(SETTING_KEYS.FAVORITE_DESKS, [], true), locker: settingSignal(SETTING_KEYS.FAVORITE_LOCKERS, [], true), parking: settingSignal(SETTING_KEYS.FAVORITE_PARKING_SPACES, [], true), diff --git a/libs/common/src/lib/constants.ts b/libs/common/src/lib/constants.ts index fe836186039..eb3668cbc77 100644 --- a/libs/common/src/lib/constants.ts +++ b/libs/common/src/lib/constants.ts @@ -15,7 +15,7 @@ export const BOOKING_TYPE_COLORS: Record = { 'group-event': ['#FCD34D', '#D97706'], }; -export const SETTING_KEYS: Record = { +export const SETTING_KEYS = { FAVORITE_ROOMS: 'favourite_rooms', FAVORITE_DESKS: 'favourite_desks', FAVORITE_PARKING_SPACES: 'favourite_parking', From 619fa1bf2a3e8edf8ec0cade21e86e4b21adf077 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 10 Nov 2025 13:44:46 +1100 Subject: [PATCH 016/392] chore(workplace): fix saving meetings in new flow --- .../meeting-flow-options.component.ts | 121 +++++++++++++++--- 1 file changed, 100 insertions(+), 21 deletions(-) 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 index b93cc8208b3..698b447f9ca 100644 --- 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 @@ -1,21 +1,36 @@ import { CommonModule, DatePipe } from '@angular/common'; -import { Component, ElementRef, inject, signal, ViewChild } from '@angular/core'; +import { + Component, + ElementRef, + inject, + signal, + ViewChild, +} from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +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, + i18n, + notifyError, + notifySuccess, OrganisationService, settingSignal, SettingsService, - i18n, } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; +import { + IconComponent, + openConfirmModal, + TranslatePipe, +} from '@placeos/components'; import { EventFormService } from '@placeos/events'; import { RichTextInputComponent, @@ -33,7 +48,9 @@ import { BehaviorSubject, combineLatest, map, tap } from 'rxjs'; class="gradient relative flex items-center space-x-2 border-l-8 border-base-content px-4 py-3 text-xl font-medium" > task_alt -
    {{ 'CALENDAR_EVENT.REVIEW_CONFIRM_HEADER' | translate }}
    +
    + {{ 'CALENDAR_EVENT.REVIEW_CONFIRM_HEADER' | translate }} +
    @@ -44,7 +61,11 @@ import { BehaviorSubject, combineLatest, map, tap } from 'rxjs'; done

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

    @@ -79,12 +100,19 @@ import { BehaviorSubject, combineLatest, map, tap } from 'rxjs'; > done
    -

    {{ 'CALENDAR_EVENT.SELECTED_ROOM_HEADER' | translate }}

    +

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

    @for (space of event.resources; track space.id) {
    - room_preferences + room_preferences
    {{ space.display_name || space.name }}
    @@ -93,19 +121,30 @@ import { BehaviorSubject, combineLatest, map, tap } from 'rxjs';
    group
    - {{ 'CALENDAR_EVENT.CAPACITY_PEOPLE' | translate: { capacity: space.capacity } }} + {{ + 'CALENDAR_EVENT.CAPACITY_PEOPLE' + | translate + : { + capacity: + space.capacity, + } + }}
    } @if (getSpaceLocation(space)) {
    - location_on + location_on
    {{ getSpaceLocation(space) }}
    } @if (space.features?.length) {
    - feature_search + feature_search
    @for ( feature of space.features.slice( @@ -125,9 +164,13 @@ import { BehaviorSubject, combineLatest, map, tap } from 'rxjs'; class="rounded-full bg-base-200 px-2 py-0.5 text-xs" > +{{ - space.features.length - 5 + space.features.length - + 5 + }} + {{ + 'CALENDAR_EVENT.FEATURES_MORE' + | translate }} - {{ 'CALENDAR_EVENT.FEATURES_MORE' | translate }} }
    @@ -142,10 +185,14 @@ import { BehaviorSubject, combineLatest, map, tap } from 'rxjs'; class="gradient relative flex items-center space-x-2 border-l-8 border-base-content px-4 py-3 text-xl font-medium" > fork_right -
    {{ 'CALENDAR_EVENT.OPTIONAL_EXTRAS_HEADER' | translate }}
    +
    + {{ 'CALENDAR_EVENT.OPTIONAL_EXTRAS_HEADER' | translate }} +
    -

    {{ 'CALENDAR_EVENT.ATTENDEES' | translate }}

    +

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

    +
    + +
    +
    + {{ + 'BOOKINGS.P2_ATTACH_SUPPORTING_DOCS' + | translate + }} +
    + + + @if (supporting_doc_names().length) { +
    + @for ( + file_name of supporting_doc_names(); + track $index + ) { +
    +
    + {{ file_name }} +
    +
    + + +
    +
    + } +
    + } +
    +
    + }
    = { IconComponent, DateFieldComponent, SettingsToggleComponent, + MatRippleModule, ], }) export class ParkingRequestFormDetailsComponent @@ -642,6 +761,8 @@ export class ParkingRequestFormDetailsComponent { private _settings = inject(SettingsService); private _org = inject(OrganisationService); + private _uploads = inject(UploadsService); + private _dialog = inject(MatDialog); public readonly form = input(undefined); public readonly show_special_needs = input(false); @@ -671,6 +792,7 @@ export class ParkingRequestFormDetailsComponent public readonly shift_type = signal('day_worker'); public readonly start_time_mins = signal(420); public readonly end_time_mins = signal(1020); + public readonly supporting_doc_names = signal([]); public readonly WEEKDAY_OPTIONS = [1, 2, 3, 4, 5, 6, 7].map((index) => ({ index, @@ -741,6 +863,12 @@ export class ParkingRequestFormDetailsComponent public ngOnInit() { const form = this.form(); if (!form) return; + this.supporting_doc_names.set( + form.value.p2_document_names || + (form.value.attachments || []).map((url) => + this._fileNameFromUrl(url), + ), + ); const is_edit = !!form.value.id; const date = form.getRawValue().date; if (is_edit && date) { @@ -844,6 +972,65 @@ export class ParkingRequestFormDetailsComponent public readonly setBuilding = (bld) => (this._org.building = bld); + public async onSupportingDocsSelected(event: Event) { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files || []); + const valid_files = files.filter( + (file) => file.size <= 10 * 1024 * 1024, + ); + if (valid_files.length !== files.length) { + notifyError('Some files exceeded 10MB and were skipped.'); + } + const existing_urls: string[] = this.form()?.value.attachments || []; + const existing_names: string[] = + this.form()?.value.p2_document_names || []; + const new_urls: string[] = []; + const uploaded_names: string[] = []; + for (const file of valid_files) { + const upload_id = await this._uploads + .uploadFile(file, true) + .catch(() => ''); + if (!upload_id) continue; + uploaded_names.push(file.name); + new_urls.push( + `${location.origin}/api/engine/v2/uploads/${encodeURIComponent(upload_id)}/url`, + ); + } + const names = [...existing_names, ...uploaded_names]; + const urls = [...existing_urls, ...new_urls]; + this.supporting_doc_names.set(names); + this.form()?.patchValue({ + p2_document_names: names, + attachments: urls, + }); + input.value = ''; + } + + public removeSupportingDoc(index: number) { + const names = [...(this.form()?.value.p2_document_names || [])]; + const urls = [...(this.form()?.value.attachments || [])]; + if (index < 0 || index >= names.length) return; + names.splice(index, 1); + if (index < urls.length) urls.splice(index, 1); + this.supporting_doc_names.set(names); + this.form()?.patchValue({ + p2_document_names: names, + attachments: urls, + }); + } + + public previewSupportingDoc(index: number) { + const urls = [...(this.form()?.value.attachments || [])]; + const url = urls[index]; + if (!url) return; + this._dialog.open(FullscreenEmbedComponent, { data: url }); + } + + private _fileNameFromUrl(url: string): string { + const last_part = `${url || ''}`.split('/').pop() || ''; + return decodeURIComponent(last_part || 'Uploaded file'); + } + public getBayInfo(bld: any): string { const metadata = bld.metadata || {}; const parking = metadata.parking?.details || {}; 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 35db9f598a0..9e637615efc 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 @@ -46,8 +46,8 @@ import { ParkingRequestFormDetailsComponent } from './parking-request-form-detai }}
    -
    -

    +

    +

    {{ 'BOOKINGS.PARKING_REQUEST_SUBTITLE' | translate }} @@ -131,9 +131,7 @@ import { ParkingRequestFormDetailsComponent } from './parking-request-form-detai @if (form.value.request_type === 'after_hours') { -

    +
    error {{ 'BOOKINGS.PARKING_AFTER_HOURS_WARNING' @@ -255,6 +253,9 @@ export class ParkingRequestFormComponent request_type: 'standard', vehicle_type: 'car', space_restrictions: false, + prefer_booked_location_first: false, + p2_document_names: [], + attachments: [], recurrence_type: 'none', }; if (!this.form.getRawValue().date) { @@ -263,9 +264,7 @@ export class ParkingRequestFormComponent defaults.duration = 600; } this.form.patchValue(defaults); - const parking_user = await nextValueFrom( - this._parking.user_details, - ); + const parking_user = await nextValueFrom(this._parking.user_details); if (parking_user?.email) { if (!this.form.value.plate_number) { this.form.patchValue({ @@ -298,6 +297,14 @@ export class ParkingRequestFormComponent description: 'Parking Request', title: this.form.value.title || 'Parking Request', }); + if ( + this.form.value.request_type === 'special' && + !`${this.form.value.notes || ''}`.trim() + ) { + return notifyError( + 'Reason for request is required for P2 Special Needs Request.', + ); + } const building = this._org.building; this.form.patchValue({ zones: [ diff --git a/libs/bookings/src/lib/booking.utilities.ts b/libs/bookings/src/lib/booking.utilities.ts index 297087a3257..a856c860589 100644 --- a/libs/bookings/src/lib/booking.utilities.ts +++ b/libs/bookings/src/lib/booking.utilities.ts @@ -125,6 +125,12 @@ export function generateBookingForm(booking: Booking = new Booking()) { recurrence_interval: new FormControl(booking.recurrence_interval), recurrence_end: new FormControl(booking.recurrence_end), notes: new FormControl(booking.extension_data.notes || ''), + p2_document_names: new FormControl( + booking.extension_data.p2_document_names || [], + ), + attachments: new FormControl( + booking.extension_data.attachments || [], + ), update_master: new FormControl(false), self_registered: new FormControl(false), is_assgined: new FormControl(false), diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 1997b51ddff..d5c39c8f952 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -673,6 +673,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": "P2 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_DAY_WORKER": "Day Worker (07:00-17:00)", diff --git a/shared/styles/application.css b/shared/styles/application.css index d75a466ec22..a57ca8a9312 100644 --- a/shared/styles/application.css +++ b/shared/styles/application.css @@ -874,6 +874,7 @@ a[icon] { font-size: 1em; padding: 0; border-radius: 9999px; + cursor: pointer; .mdc-button-wrapper { display: flex; From d62472deb9d90e8c06575bf1a2e60fc9f7b639ef Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 26 Feb 2026 14:11:18 +1100 Subject: [PATCH 173/392] chore(concierge): tweaks to parking request table --- .../parking-assign-space-modal.component.ts | 16 +++- .../parking-requests-list.component.ts | 76 +++++++++++----- ...parking-special-request-modal.component.ts | 90 +++++++++++++++++++ .../src/app/parking/parking.module.ts | 2 + 4 files changed, 161 insertions(+), 23 deletions(-) create mode 100644 apps/concierge/src/app/parking/parking-special-request-modal.component.ts 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 2a66b502d42..c527b47f1b3 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 @@ -120,7 +120,7 @@ import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
    -
    {{ space.name || space.id }}
    +
    {{ space_label(space) }}
    } @@ -304,6 +304,7 @@ export class ParkingAssignSpaceModalComponent hover: true, data: { ...space, + name: this.space_label(space), user: space.assigned_to || '', plate_number: '', status: is_selected @@ -345,17 +346,26 @@ export class ParkingAssignSpaceModalComponent this._refreshStyles(); } + public readonly space_label = (space: PlaceAsset) => + (space as any)?.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(); if (!space) return; this.loading.set(true); try { + const asset_name = this.space_label(space); await updateBooking(this._data.booking.id, { asset_id: space.id, - asset_name: space.name, + asset_name, extension_data: { ...this._data.booking.extension_data, - asset_name: space.name, + asset_name, }, } as any).toPromise(); await approveBooking(this._data.booking.id).toPromise(); diff --git a/apps/concierge/src/app/parking/parking-requests-list.component.ts b/apps/concierge/src/app/parking/parking-requests-list.component.ts index 15859b38d36..a07f74b87fa 100644 --- a/apps/concierge/src/app/parking/parking-requests-list.component.ts +++ b/apps/concierge/src/app/parking/parking-requests-list.component.ts @@ -1,16 +1,18 @@ import { CommonModule } from '@angular/common'; import { Component, OnInit, inject } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; import { MatMenuModule } from '@angular/material/menu'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { AsyncHandler, SettingsService } from '@placeos/common'; +import { AsyncHandler, Booking, SettingsService } from '@placeos/common'; import { IconComponent, SimpleTableComponent, TranslatePipe, } from '@placeos/components'; import { combineLatest, map } from 'rxjs'; +import { ParkingSpecialRequestModalComponent } from './parking-special-request-modal.component'; import { ParkingStateService } from './parking-state.service'; @Component({ @@ -36,6 +38,12 @@ import { ParkingStateService } from './parking-state.service'; name: 'FORM.TIME' | translate, content: date_template, }, + { + key: 'request_type', + name: 'BOOKINGS.PARKING_REQUEST_TYPE' | translate, + content: request_type_template, + size: '9rem', + }, { key: 'user_name', name: 'APP.CONCIERGE.PARKING_RESERVED_FOR' | translate, @@ -63,7 +71,7 @@ import { ParkingStateService } from './parking-state.service'; key: 'actions', name: ' ', content: action_template, - size: '6.5rem', + size: '6rem', sortable: false, }, ]" @@ -82,6 +90,11 @@ import { ParkingStateService } from './parking-state.service'; }}
    + +
    + {{ request_type_label(request_type(row)) | translate }} +
    +
    {{ row.user_name || row.user_email }}
    @@ -207,21 +220,19 @@ import { ParkingStateService } from './parking-state.service'; -
    +
    @@ -260,6 +272,7 @@ export class ParkingRequestsListComponent { private _state = inject(ParkingStateService); private _settings = inject(SettingsService); + private _dialog = inject(MatDialog); public readonly options = this._state.options; public readonly loading = this._state.loading; @@ -273,6 +286,13 @@ export class ParkingRequestsListComponent b.asset_id?.startsWith('unallocated'), ); const s = search.toLowerCase(); + const type_index = (i) => + this.request_type(i) == 'special' + ? 2 + : this.request_type(i) == 'after_hours' + ? 1 + : 0; + unallocated.sort((a, b) => type_index(a) - type_index(b)); return !s ? unallocated : unallocated.filter( @@ -286,10 +306,26 @@ export class ParkingRequestsListComponent }), ); - public readonly reject = (e) => this._state.rejectBooking(e); - public readonly approve = (e) => this._state.approveBooking(e); - public readonly editReservation = (e) => this._state.editReservation(e); - public readonly assignSpace = (e) => this._state.assignSpace(e); + public readonly reject = (e: Booking) => this._state.rejectBooking(e); + public readonly approve = (e: Booking) => this._state.approveBooking(e); + public readonly editReservation = (e: Booking) => + this._state.editReservation(e); + public readonly assignSpace = (e: Booking) => this._state.assignSpace(e); + public readonly viewSpecialNeedsRequest = (booking: Booking) => + this._dialog.open(ParkingSpecialRequestModalComponent, { + data: { booking }, + }); + public readonly request_type = (booking: Booking) => + booking?.extension_data?.request_type || ''; + + private readonly _request_type_labels: Record = { + standard: 'Standard', + special: 'P2', + after_hours: 'After-hours', + }; + + public readonly request_type_label = (request_type: string) => + this._request_type_labels[request_type] || 'COMMON.EMPTY'; public get time_format() { return this._settings.time_format; diff --git a/apps/concierge/src/app/parking/parking-special-request-modal.component.ts b/apps/concierge/src/app/parking/parking-special-request-modal.component.ts new file mode 100644 index 00000000000..1664d301520 --- /dev/null +++ b/apps/concierge/src/app/parking/parking-special-request-modal.component.ts @@ -0,0 +1,90 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { Booking } from '@placeos/common'; +import { + FullscreenModalShellComponent, + IconComponent, + TranslatePipe, +} from '@placeos/components'; + +@Component({ + selector: 'parking-special-request-modal', + template: ` + +
    +
    +
    + {{ 'BOOKINGS.P2_REASON_FOR_REQUEST' | translate }} +
    +
    + {{ + request_notes || + ('COMMON.EMPTY' | translate) + }} +
    +
    + +
    +
    + {{ 'BOOKINGS.P2_ATTACH_SUPPORTING_DOCS' | translate }} +
    + @if (attachments.length) { +
    + @for (file of attachments; track file.url) { + + attach_file + + {{ file.name }} + + + } +
    + } @else { +
    + {{ 'COMMON.EMPTY' | translate }} +
    + } +
    +
    +
    + `, + styles: [``], + imports: [ + CommonModule, + TranslatePipe, + IconComponent, + FullscreenModalShellComponent, + ], +}) +export class ParkingSpecialRequestModalComponent { + private _data = inject<{ booking: Booking }>(MAT_DIALOG_DATA); + + public readonly request_notes: string = + `${this._data.booking?.extension_data?.notes || this._data.booking?.description || ''}`.trim(); + + public readonly attachments: { name: string; url: string }[] = (() => { + const urls: string[] = ( + this._data.booking?.extension_data?.attachments || [] + ).filter((url) => !!url); + const names: string[] = + this._data.booking?.extension_data?.p2_document_names || []; + return urls.map((url, index) => ({ + url, + name: names[index] || this._fileNameFromUrl(url), + })); + })(); + + private _fileNameFromUrl(url: string): string { + const last_part = `${url || ''}`.split('/').pop() || ''; + return decodeURIComponent(last_part || 'Uploaded file'); + } +} diff --git a/apps/concierge/src/app/parking/parking.module.ts b/apps/concierge/src/app/parking/parking.module.ts index e94480290c6..5c6bc8f3325 100644 --- a/apps/concierge/src/app/parking/parking.module.ts +++ b/apps/concierge/src/app/parking/parking.module.ts @@ -4,6 +4,7 @@ import { Route, RouterModule } from '@angular/router'; import { ParkingBookingsListComponent } from './parking-bookings-list.component'; import { ParkingMapComponent } from './parking-map.component'; import { ParkingRequestsListComponent } from './parking-requests-list.component'; +import { ParkingSpecialRequestModalComponent } from './parking-special-request-modal.component'; import { ParkingSpaceListComponent } from './parking-space-list.component'; import { ParkingUsersListComponent } from './parking-users-list.component'; import { ParkingComponent } from './parking.component'; @@ -50,6 +51,7 @@ const ROUTES: Route[] = [ ParkingComponent, ParkingBookingsListComponent, ParkingRequestsListComponent, + ParkingSpecialRequestModalComponent, ParkingSpaceListComponent, ParkingUsersListComponent, ParkingMapComponent, From 240ca8a5f276f13b2963a425d7ee21ea57c41057 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 26 Feb 2026 14:59:55 +1100 Subject: [PATCH 174/392] feat(concierge): add week view for parking requests and bookings --- .../parking-bookings-list.component.ts | 132 ++++---- .../parking-bookings-week-view.component.ts | 255 ++++++++++++++ .../parking-requests-list.component.ts | 135 ++++---- .../parking-requests-week-view.component.ts | 313 ++++++++++++++++++ .../src/app/parking/parking-state.service.ts | 30 +- .../app/parking/parking-topbar.component.ts | 51 ++- 6 files changed, 794 insertions(+), 122 deletions(-) create mode 100644 apps/concierge/src/app/parking/parking-bookings-week-view.component.ts create mode 100644 apps/concierge/src/app/parking/parking-requests-week-view.component.ts 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 60da736b39f..c747e1a047b 100644 --- a/apps/concierge/src/app/parking/parking-bookings-list.component.ts +++ b/apps/concierge/src/app/parking/parking-bookings-list.component.ts @@ -4,13 +4,14 @@ import { MatRippleModule } from '@angular/material/core'; import { MatMenuModule } from '@angular/material/menu'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { AsyncHandler, SettingsService } from '@placeos/common'; +import { AsyncHandler, Booking, SettingsService } from '@placeos/common'; import { IconComponent, SimpleTableComponent, TranslatePipe, } from '@placeos/components'; import { combineLatest, map } from 'rxjs'; +import { ParkingBookingsWeekViewComponent } from './parking-bookings-week-view.component'; import { ParkingStateService } from './parking-state.service'; @Component({ @@ -20,61 +21,75 @@ import { ParkingStateService } from './parking-state.service'; [class.opacity-0]="!(loading | async)?.includes('bookings')" class="sticky left-0 w-full" /> - + @if ((options | async)?.period === 'week') { + + } @else { + + }
    {{ @@ -241,6 +256,7 @@ import { ParkingStateService } from './parking-state.service'; MatMenuModule, MatTooltipModule, IconComponent, + ParkingBookingsWeekViewComponent, ], }) export class ParkingBookingsListComponent @@ -289,6 +305,10 @@ export class ParkingBookingsListComponent return this._settings.time_format; } + public get week_start() { + return this._settings.get('app.week_start') || 0; + } + public ngOnInit() { this.subscription('poll', this._state.startPolling()); } diff --git a/apps/concierge/src/app/parking/parking-bookings-week-view.component.ts b/apps/concierge/src/app/parking/parking-bookings-week-view.component.ts new file mode 100644 index 00000000000..1978f82abfa --- /dev/null +++ b/apps/concierge/src/app/parking/parking-bookings-week-view.component.ts @@ -0,0 +1,255 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, input } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Booking } from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { addDays, format, isSameDay, startOfWeek } from 'date-fns'; + +@Component({ + selector: 'parking-bookings-week-view', + template: ` +
    +
    + @for (date of days(); track date) { +
    +
    + {{ date | date: 'EEE, MMM d' }} +
    + @if (is_today(date)) { +
    + {{ 'COMMON.TODAY' | translate }} +
    + } +
    +
    + } +
    +
    + @for (date of days(); track date) { +
    + @for ( + event of events_map()[date] || []; + track event.id + '-' + event.instance + ) { +
    +
    +
    + {{ event.asset_name }} +
    + +
    +
    + {{ + event.all_day || + event.duration > 12 * 60 + ? ('COMMON.ALL_DAY' | translate) + : (event.date + | date: time_format()) + + ' - ' + + (event.date_end + | date: time_format()) + }} +
    +
    + {{ event.user_name || event.user_email }} +
    +
    + {{ event?.extension_data?.plate_number }} +
    +
    +
    + {{ + status_label(event?.status) + | translate + }} +
    +
    + + +
    +
    +
    + } @empty { +
    + No bookings +
    + } +
    + } +
    +
    + `, + styles: [ + ` + :host { + display: block; + height: 100%; + } + + [timeline] { + grid-template-rows: 3rem auto; + } + `, + ], + imports: [ + CommonModule, + MatRippleModule, + MatTooltipModule, + IconComponent, + TranslatePipe, + ], +}) +export class ParkingBookingsWeekViewComponent { + public readonly booking_events = input([]); + public readonly date = input(Date.now()); + public readonly week_start = input(0); + public readonly time_format = input('shortTime'); + + public readonly approve = input<(e: Booking) => void>(() => undefined); + public readonly reject = input<(e: Booking) => void>(() => undefined); + public readonly edit_reservation = input<(e: Booking) => void>( + () => undefined, + ); + + public readonly days = computed(() => + new Array(7).fill(0).map((_, index) => + addDays( + startOfWeek(this.date(), { + weekStartsOn: this.week_start() as + | 0 + | 1 + | 2 + | 3 + | 4 + | 5 + | 6, + }), + index, + ).valueOf(), + ), + ); + + public readonly events_map = computed(() => { + const data: Record = {}; + const day_list = this.days(); + const events = this.booking_events(); + for (const day of day_list) { + const day_key = format(day, 'yyyy-MM-dd'); + data[day] = events + .filter((event) => format(event.date, 'yyyy-MM-dd') === day_key) + .sort((a, b) => a.date - b.date); + } + return data; + }); + + public readonly is_today = (date: number) => isSameDay(date, Date.now()); + + public readonly status_label = (status: string) => + status === 'ended' + ? 'APP.CONCIERGE.BOOKING_STATUS_ENDED' + : status === 'approved' + ? 'APP.CONCIERGE.BOOKING_STATUS_APPROVED' + : status === 'declined' + ? 'APP.CONCIERGE.BOOKING_STATUS_DECLINED' + : 'APP.CONCIERGE.BOOKING_STATUS_PENDING'; +} diff --git a/apps/concierge/src/app/parking/parking-requests-list.component.ts b/apps/concierge/src/app/parking/parking-requests-list.component.ts index a07f74b87fa..5aa5f0e79ed 100644 --- a/apps/concierge/src/app/parking/parking-requests-list.component.ts +++ b/apps/concierge/src/app/parking/parking-requests-list.component.ts @@ -13,6 +13,7 @@ import { } from '@placeos/components'; import { combineLatest, map } from 'rxjs'; import { ParkingSpecialRequestModalComponent } from './parking-special-request-modal.component'; +import { ParkingRequestsWeekViewComponent } from './parking-requests-week-view.component'; import { ParkingStateService } from './parking-state.service'; @Component({ @@ -22,63 +23,78 @@ import { ParkingStateService } from './parking-state.service'; [class.opacity-0]="!(loading | async)?.includes('bookings')" class="sticky left-0 w-full" /> - + @if ((options | async)?.period === 'week') { + + } @else { + + }
    {{ @@ -264,6 +280,7 @@ import { ParkingStateService } from './parking-state.service'; MatMenuModule, MatTooltipModule, IconComponent, + ParkingRequestsWeekViewComponent, ], }) export class ParkingRequestsListComponent @@ -331,6 +348,10 @@ export class ParkingRequestsListComponent return this._settings.time_format; } + public get week_start() { + return this._settings.get('app.week_start') || 0; + } + public ngOnInit() { this.subscription('poll', this._state.startPolling()); } diff --git a/apps/concierge/src/app/parking/parking-requests-week-view.component.ts b/apps/concierge/src/app/parking/parking-requests-week-view.component.ts new file mode 100644 index 00000000000..1b8cb0db815 --- /dev/null +++ b/apps/concierge/src/app/parking/parking-requests-week-view.component.ts @@ -0,0 +1,313 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, input } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Booking } from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { addDays, format, isSameDay, startOfWeek } from 'date-fns'; + +@Component({ + selector: 'parking-requests-week-view', + template: ` +
    +
    + + {{ request_type_label('standard') | translate }} +
    +
    + + {{ request_type_label('after_hours') | translate }} +
    +
    + + {{ request_type_label('special') | translate }} +
    +
    +
    +
    + @for (date of days(); track date) { +
    +
    + {{ date | date: 'EEE, MMM d' }} +
    + @if (is_today(date)) { +
    + {{ 'COMMON.TODAY' | translate }} +
    + } +
    +
    + } +
    +
    + @for (date of days(); track date) { +
    + @for ( + event of events_map()[date] || []; + track event.id + '-' + event.instance + ) { +
    +
    + @if (request_type(event) === 'special') { + + } +
    +
    + {{ + event.all_day || + event.duration > 12 * 60 + ? ('COMMON.ALL_DAY' | translate) + : (event.date + | date: time_format()) + + ' - ' + + (event.date_end + | date: time_format()) + }} +
    +
    + {{ event.user_name || event.user_email }} +
    +
    + {{ event?.extension_data?.plate_number }} +
    +
    +
    + {{ + status_label(event?.status) + | translate + }} +
    +
    + + + +
    +
    +
    + } @empty { +
    + No requests +
    + } +
    + } +
    +
    + `, + styles: [ + ` + :host { + display: block; + height: 100%; + } + + [timeline] { + grid-template-rows: 3rem auto; + } + `, + ], + imports: [ + CommonModule, + MatRippleModule, + MatTooltipModule, + IconComponent, + TranslatePipe, + ], +}) +export class ParkingRequestsWeekViewComponent { + public readonly booking_events = input([]); + public readonly date = input(Date.now()); + public readonly week_start = input(0); + public readonly time_format = input('shortTime'); + + public readonly approve = input<(e: Booking) => void>(() => undefined); + public readonly reject = input<(e: Booking) => void>(() => undefined); + public readonly assign_space = input<(e: Booking) => void>(() => undefined); + public readonly view_special_needs_request = input<(e: Booking) => void>( + () => undefined, + ); + + public readonly days = computed(() => + new Array(7).fill(0).map((_, index) => + addDays( + startOfWeek(this.date(), { + weekStartsOn: this.week_start() as + | 0 + | 1 + | 2 + | 3 + | 4 + | 5 + | 6, + }), + index, + ).valueOf(), + ), + ); + + public readonly events_map = computed(() => { + const data: Record = {}; + const day_list = this.days(); + const events = this.booking_events(); + for (const day of day_list) { + const day_key = format(day, 'yyyy-MM-dd'); + data[day] = events + .filter((event) => format(event.date, 'yyyy-MM-dd') === day_key) + .sort((a, b) => a.date - b.date); + } + return data; + }); + + public readonly is_today = (date: number) => isSameDay(date, Date.now()); + + public readonly request_type = (booking: Booking) => + booking?.extension_data?.request_type || ''; + + public readonly request_type_color = (request_type: string) => + request_type === 'special' + ? '#ef4444' + : request_type === 'after_hours' + ? '#f59e0b' + : '#3b82f6'; + private readonly _request_type_labels: Record = { + standard: 'Standard', + special: 'P2', + after_hours: 'After-hours', + }; + + public readonly request_type_label = (request_type: string) => + this._request_type_labels[request_type] || 'COMMON.EMPTY'; + + public readonly status_label = (status: string) => + status === 'ended' + ? 'APP.CONCIERGE.BOOKING_STATUS_ENDED' + : status === 'approved' + ? 'APP.CONCIERGE.BOOKING_STATUS_APPROVED' + : status === 'declined' + ? 'APP.CONCIERGE.BOOKING_STATUS_DECLINED' + : 'APP.CONCIERGE.BOOKING_STATUS_PENDING'; +} diff --git a/apps/concierge/src/app/parking/parking-state.service.ts b/apps/concierge/src/app/parking/parking-state.service.ts index 9a38b1e1268..52aee77bbb7 100644 --- a/apps/concierge/src/app/parking/parking-state.service.ts +++ b/apps/concierge/src/app/parking/parking-state.service.ts @@ -36,7 +36,15 @@ import { import { openConfirmModal } from '@placeos/components'; import { PlaceAsset } from '@placeos/ts-client'; import { UserPipe } from '@placeos/users'; -import { addHours, endOfDay, getUnixTime, set, startOfDay } from 'date-fns'; +import { + addHours, + endOfDay, + endOfWeek, + getUnixTime, + set, + startOfDay, + startOfWeek, +} from 'date-fns'; import { BehaviorSubject, combineLatest, of } from 'rxjs'; import { debounceTime, @@ -54,6 +62,7 @@ import { ParkingUserModalComponent } from './parking-user-modal.component'; export interface ParkingOptions { date: number; + period: 'day' | 'week'; search: string; zones: string[]; } @@ -76,6 +85,7 @@ export class ParkingStateService extends AsyncHandler { private _change = new BehaviorSubject(0); private _options = new BehaviorSubject({ date: Date.now(), + period: 'day', search: '', zones: [], }); @@ -156,10 +166,20 @@ export class ParkingStateService extends AsyncHandler { ]).pipe( debounceTime(500), switchMap(([bld, options, users]) => { + const period_start = + options.period === 'week' + ? startOfWeek(options.date, { + weekStartsOn: this._week_start, + }) + : startOfDay(options.date); + const period_end = + options.period === 'week' + ? endOfWeek(options.date, { weekStartsOn: this._week_start }) + : endOfDay(options.date); this._loading.next([...this._loading.getValue(), '[BOOKINGS]']); return queryBookings({ - period_start: getUnixTime(startOfDay(options.date)), - period_end: getUnixTime(endOfDay(options.date)), + period_start: getUnixTime(period_start), + period_end: getUnixTime(period_end), type: 'parking', zones: options.zones?.length ? options.zones.join(',') @@ -493,4 +513,8 @@ export class ParkingStateService extends AsyncHandler { const filtered = booking_list.filter((_) => _.asset_id === space.id); await Promise.all(filtered.map((_) => removeBooking(_.id).toPromise())); } + + private get _week_start(): 0 | 1 | 2 | 3 | 4 | 5 | 6 { + return this._settings.get('app.week_start') || 0; + } } diff --git a/apps/concierge/src/app/parking/parking-topbar.component.ts b/apps/concierge/src/app/parking/parking-topbar.component.ts index ba47702d10c..34deff6a4f3 100644 --- a/apps/concierge/src/app/parking/parking-topbar.component.ts +++ b/apps/concierge/src/app/parking/parking-topbar.component.ts @@ -128,7 +128,10 @@ import { ParkingStateService } from './parking-state.service';
    } @if (!is_requests_view()) { - + } + @if (section() === 'events' && view() !== 'map') { + + + + {{ 'COMMON.DAY' | translate }} + + + {{ 'COMMON.WEEK' | translate }} + + + + }
    @if (section() === 'manage') {
    `, @@ -255,6 +276,15 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { public readonly bookings = this._state.bookings; /** Set filtered date */ public readonly setDate = (d) => this._state.setOptions({ date: d }); + /** Set selected period */ + public readonly setPeriod = (period: 'day' | 'week') => { + this._router.navigate([], { + relativeTo: this._route, + queryParams: { period }, + queryParamsHandling: 'merge', + }); + this._state.setOptions({ period }); + }; /** Set filter string */ public readonly setSearch = (str) => this._state.setOptions({ search: str }); @@ -292,6 +322,12 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { this.subscription( 'route.query', this._route.queryParamMap.subscribe((params) => { + if (params.has('period')) { + this._state.setOptions({ + period: + params.get('period') === 'week' ? 'week' : 'day', + }); + } if (this.is_requests_view()) { this.clearZones(); return; @@ -368,9 +404,8 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { } private clearZones() { - const has_query_param = this._route.snapshot.queryParamMap.has( - 'zone_ids', - ); + const has_query_param = + this._route.snapshot.queryParamMap.has('zone_ids'); if (!this.zones.length && !has_query_param) { this._state.setOptions({ zones: [] }); return; @@ -380,7 +415,11 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { } private async selectDefaultZoneForManage() { - if (this.section() !== 'manage' || this.use_region || this.zones.length) { + if ( + this.section() !== 'manage' || + this.use_region || + this.zones.length + ) { return; } const levels = await nextValueFrom(this.levels); From c4be842f262916989610ae473a9b509a9b5db6fc Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 26 Feb 2026 15:05:47 +1100 Subject: [PATCH 175/392] chore(concierge): tweaks to list display of parking requests --- .../parking-requests-list.component.ts | 50 +++++++++++++++++-- libs/common/src/lib/types/booking.class.ts | 2 +- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/apps/concierge/src/app/parking/parking-requests-list.component.ts b/apps/concierge/src/app/parking/parking-requests-list.component.ts index 5aa5f0e79ed..44021034f4e 100644 --- a/apps/concierge/src/app/parking/parking-requests-list.component.ts +++ b/apps/concierge/src/app/parking/parking-requests-list.component.ts @@ -12,8 +12,8 @@ import { TranslatePipe, } from '@placeos/components'; import { combineLatest, map } from 'rxjs'; -import { ParkingSpecialRequestModalComponent } from './parking-special-request-modal.component'; import { ParkingRequestsWeekViewComponent } from './parking-requests-week-view.component'; +import { ParkingSpecialRequestModalComponent } from './parking-special-request-modal.component'; import { ParkingStateService } from './parking-state.service'; @Component({ @@ -36,7 +36,7 @@ import { ParkingStateService } from './parking-state.service'; /> } @else { +
    + @if (request_submitted_at(row)) { + {{ + request_submitted_at(row) + | date: 'MMM d, ' + time_format + }} + } @else { + {{ 'COMMON.EMPTY' | translate }} + } +
    +
    {{ row.user_name || row.user_email }}
    @@ -309,7 +328,15 @@ export class ParkingRequestsListComponent : this.request_type(i) == 'after_hours' ? 1 : 0; - unallocated.sort((a, b) => type_index(a) - type_index(b)); + unallocated.sort((a, b) => { + const type_diff = type_index(a) - type_index(b); + if (type_diff !== 0) return type_diff; + const submitted_a = this.request_submitted_at(a); + const submitted_b = this.request_submitted_at(b); + if (submitted_a !== submitted_b) + return submitted_b - submitted_a; + return b.date - a.date; + }); return !s ? unallocated : unallocated.filter( @@ -334,6 +361,23 @@ export class ParkingRequestsListComponent }); public readonly request_type = (booking: Booking) => booking?.extension_data?.request_type || ''; + public readonly request_submitted_at = (booking: Booking): number => { + const value = + booking?.extension_data?.submitted_at || + booking?.extension_data?.submission_date || + booking?.extension_data?.created_at || + booking?.extension_data?.created || + 0; + if (!value) return 0; + if (typeof value === 'string') { + const parsed_value = Date.parse(value); + return Number.isFinite(parsed_value) ? parsed_value : 0; + } + if (typeof value === 'number') { + return value < 1_000_000_000_000 ? value * 1000 : value; + } + return 0; + }; private readonly _request_type_labels: Record = { standard: 'Standard', diff --git a/libs/common/src/lib/types/booking.class.ts b/libs/common/src/lib/types/booking.class.ts index 171c598d884..ef12ac4098f 100644 --- a/libs/common/src/lib/types/booking.class.ts +++ b/libs/common/src/lib/types/booking.class.ts @@ -172,7 +172,7 @@ export class Booking { } public get is_all_day() { - return this.all_day || this.duration >= 12 * 60; + return this.all_day || this.duration >= 18 * 60; } _valid_asset_cache = []; From 4853b286c15982196d1a132355b9803b14e6e148 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 27 Feb 2026 13:49:33 +1100 Subject: [PATCH 176/392] chore(workplace): tweak handling group booking edits --- .../visitor-flow-details.component.ts | 13 ++- .../visitor-flow.component.ts | 83 ++++++++++++------- 2 files changed, 64 insertions(+), 32 deletions(-) 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 index 01475c5fef2..c79567b3dea 100644 --- 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 @@ -1,5 +1,5 @@ import { AsyncPipe } from '@angular/common'; -import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { Component, computed, effect, inject, OnInit, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; @@ -223,6 +223,17 @@ export class VisitorFlowDetailsComponent implements OnInit { private _org = inject(OrganisationService); private _settings = inject(SettingsService); + private _options = toSignal(this._booking_form.options, { + initialValue: { type: 'visitor' as const, group: false }, + }); + + 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' }, 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 index 39d586b2060..d012a372f34 100644 --- 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 @@ -5,12 +5,11 @@ import { ActivatedRoute, Router } from '@angular/router'; import { BookingFormService } from '@placeos/bookings'; import { AsyncHandler, - currentUser, + Booking, getInvalidFields, i18n, notifyError, notifySuccess, - randomString, User, } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; @@ -131,6 +130,7 @@ export class VisitorFlowNewComponent extends AsyncHandler implements OnInit { private _booking_form = inject(BookingFormService); private _router = inject(Router); private _route = inject(ActivatedRoute); + private _existing_siblings: Booking[] = []; public readonly view = this._booking_form.view; public readonly loading = signal(false); @@ -156,6 +156,36 @@ export class VisitorFlowNewComponent extends AsyncHandler implements OnInit { this._booking_form.setView(param.get('step') as any); }), ); + this._loadGroupVisitors(); + } + + private async _loadGroupVisitors() { + const value = this._booking_form.form.getRawValue(); + 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._booking_form.form.patchValue({ + assets: visitors, + asset_id: 'multiple@place.tech', + }); + this._booking_form.setOptions({ group: true }); } public async confirmBooking() { @@ -232,38 +262,29 @@ export class VisitorFlowNewComponent extends AsyncHandler implements OnInit { } private async _bookForMany() { - const group = `grp-${randomString(8)}`; const value = this._booking_form.form.getRawValue(); const visitor_reason = value.title || value.description || 'Visit'; - const assets = [...(value.assets || [])]; - let is_first = true; - - for (const user of assets) { - if (!user.email) continue; - const booking_id = value.id && is_first ? value.id : ''; - is_first = false; - this._booking_form.form.patchValue({ - ...value, - id: booking_id, - booking_type: 'visitor', - asset_id: user.email, - asset_name: user.name, - user: currentUser(), - title: visitor_reason, - description: visitor_reason, - group, - name: user.name, - assets: [], - attendees: [ + const assets: User[] = value.assets || []; + const visitor_members = assets + .filter((_) => !!_.email) + .map( + (user) => new User({ - name: user.name, - email: user.email, - organisation: user.company || user.organisation, - phone: user.phone, - }), - ], - }); - await this._booking_form.postForm(); + ...user, + name: user.name || user.email, + } as any), + ); + this._booking_form.setOptions({ + type: 'visitor', + group: true, + members: visitor_members, + }); + if (value.id && this._existing_siblings.length) { + await this._booking_form.editFormForGroup( + this._existing_siblings, + ); + } else { + await this._booking_form.postFormForVisitorGroup(); } } } From db14bf47e5ff0ad94077085c24ebf6dca9f04df5 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 27 Feb 2026 13:58:23 +1100 Subject: [PATCH 177/392] chore(workplace): fix saving visitor details for future use --- .../visitor-flow.component.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) 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 index d012a372f34..23c94f33d2f 100644 --- 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 @@ -10,6 +10,7 @@ import { i18n, notifyError, notifySuccess, + SettingsService, User, } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; @@ -128,6 +129,7 @@ import { VisitorFlowSuccessComponent } from './visitor-flow-success.component'; }) 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 _existing_siblings: Booking[] = []; @@ -223,6 +225,7 @@ export class VisitorFlowNewComponent extends AsyncHandler implements OnInit { this._booking_form.last_count = is_multiple ? this.form_value()?.assets?.length || 1 : 1; + this._saveRecentVisitors(is_multiple); await (is_multiple ? this._bookForMany() : this._bookForOne()); const name = is_multiple ? i18n('BOOKINGS.VISITORS') : asset_name; notifySuccess( @@ -245,6 +248,35 @@ export class VisitorFlowNewComponent extends AsyncHandler implements OnInit { } } + private _saveRecentVisitors(is_multiple: boolean) { + const old_visitors: string[] = + this._settings.get('visitor-invitees') || []; + const value = this._booking_form.form.getRawValue(); + const toEntry = (email: string, name = '', org = '') => + `${email}|${name}|${org}`; + if (is_multiple && value.assets?.length) { + const emails = new Set( + value.assets.map((a) => a.email).filter(Boolean), + ); + this._settings.saveUserSetting('visitor-invitees', [ + ...old_visitors.filter( + (v) => !emails.has(`${v}`.split('|')[0]), + ), + ...value.assets + .filter((a) => !!a.email) + .map((a) => toEntry(a.email, a.name, a.organisation)), + ]); + } else { + const { asset_id, asset_name, company } = value; + this._settings.saveUserSetting('visitor-invitees', [ + ...old_visitors.filter( + (v) => `${v}`.split('|')[0] !== asset_id, + ), + toEntry(asset_id, asset_name, company), + ]); + } + } + private async _bookForOne() { const value = this._booking_form.form.value; this._booking_form.form.patchValue({ From 3e5c455c13a3243f45bfe6487a1562637ad879b3 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 27 Feb 2026 14:22:51 +1100 Subject: [PATCH 178/392] chore(workplace): tweak setting asset_id when booking desks --- .../book/desk-flow-new/desk-flow-select.component.ts | 11 +++++++++-- apps/workplace/src/app/book/desk-flow.component.ts | 1 + .../app/book/desk-flow/desk-form-details.component.ts | 5 ++++- 3 files changed, 14 insertions(+), 3 deletions(-) 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 index 95a06f9b383..b9eeada68aa 100644 --- 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 @@ -779,6 +779,7 @@ export class DeskFlowSelectComponent extends AsyncHandler implements OnInit { ) { this.form().patchValue({ resources: [matching_resource], + asset_id: matching_resource.id, }); this.selected.set([matching_resource.id]); } @@ -849,10 +850,16 @@ export class DeskFlowSelectComponent extends AsyncHandler implements OnInit { const new_resources = resources.find(({ id }) => id === space.id) ? resources.filter(({ id }) => id !== space.id) : [...resources, space]; - this.form().patchValue({ resources: new_resources }); + this.form().patchValue({ + resources: new_resources, + asset_id: new_resources[0]?.id || '', + }); this.selected.set(new_resources.map(({ id }) => id)); } else { - this.form().patchValue({ resources: [space] }); + this.form().patchValue({ + 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.component.ts b/apps/workplace/src/app/book/desk-flow.component.ts index 4be88b7e5fd..976a5c89e7e 100644 --- a/apps/workplace/src/app/book/desk-flow.component.ts +++ b/apps/workplace/src/app/book/desk-flow.component.ts @@ -100,6 +100,7 @@ export class NewDeskFlowComponent extends AsyncHandler implements OnInit { ); } this._state.form.patchValue({ + asset_id: asset.id, resources: [ new Desk({ id: asset.id, 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 6aa990aa379..2d567658449 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 @@ -440,7 +440,10 @@ export class NewDeskFormDetailsComponent const form = this.form(); if (changes.form && form) { if (this.selected_desk?.id) { - form.patchValue({ resources: [this.selected_desk] }); + form.patchValue({ + resources: [this.selected_desk], + asset_id: this.selected_desk.id, + }); } } } From f183705b954895e6c11da55b3561e5224e9ce695 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 27 Feb 2026 16:51:57 +1100 Subject: [PATCH 179/392] chore(workplace): fix updating desks after delete --- .../src/app/landing-new/landing-desk-week.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 40cf2e2d191..00c4fbc0abf 100644 --- a/apps/workplace/src/app/landing-new/landing-desk-week.component.ts +++ b/apps/workplace/src/app/landing-new/landing-desk-week.component.ts @@ -562,8 +562,8 @@ export class LandingDeskWeekComponent data: { booking, edit_fn: (b) => this._schedule.editBooking(b), - remove_fn: (b, s) => { - this._schedule.remove(b, s); + remove_fn: async (b, s) => { + await this._schedule.remove(b, s); this.selected_date.set(Date.now()); }, end_fn: (b) => this._schedule.end(b), From 940e616fb3fc55b8150d2338f028bd2f4fe98153 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Tue, 3 Mar 2026 13:53:42 +1100 Subject: [PATCH 180/392] chore: tweaks --- .../meeting-flow-success.component.ts | 21 +- .../src/lib/catering-list-field.component.ts | 281 ++++++++---------- 2 files changed, 145 insertions(+), 157 deletions(-) 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 ff544152241..828b279dae7 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 @@ -163,12 +163,21 @@ export class MeetingFlowSuccessComponent implements OnInit { public async ngOnInit() { this.loading.set(true); - await firstTruthyValueFrom(this._org.initialised); - const space = this.last_event()?.space; - if (space) { - this.space.set( - await this._space_pipe.transform(space.email || space.id), - ); + if ((this._org as any).initialised) { + await firstTruthyValueFrom((this._org as any).initialised); + } + const event_space = this.last_event()?.space; + if (event_space) { + this.space.set(new Space(event_space)); + try { + this.space.set( + await this._space_pipe.transform( + event_space.email || event_space.id, + ), + ); + } catch { + /* Falls back to event space details when org data is unavailable */ + } } setTimeout(() => this.loading.set(false), 500); } diff --git a/libs/catering/src/lib/catering-list-field.component.ts b/libs/catering/src/lib/catering-list-field.component.ts index 51cbd60cd1d..fc19743b751 100644 --- a/libs/catering/src/lib/catering-list-field.component.ts +++ b/libs/catering/src/lib/catering-list-field.component.ts @@ -73,180 +73,159 @@ const EMPTY_FAVS = [];
    {{ - 'CALENDAR_EVENT.CATERING_ORDER_AT_DATE' + 'CALENDAR_EVENT.CATERING_ORDER_DETAILS' | translate : { - date: - order.deliver_at_time - | date: 'mediumDate', - time: - order.deliver_at_time - | 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', - } - }} - - } -
    + @for (item of order.items; track item.custom_id) {
    - x{{ item.quantity }} -
    -
    - {{ - 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 + +
    + } +
    -
    - } + } +
    -
    + + + + +
    + }
    @if (buildings && (buildings | async)?.length > 1) {
    @@ -153,6 +156,7 @@ type VisitorFormType = 'single' | 'group'; form.patchValue({ date: $event }) " [ngModelOptions]="{ standalone: true }" + [disabled]="is_edit_in_progress()" [use_24hr]="use_24hr()" />
    @@ -233,6 +237,14 @@ export class VisitorFlowDetailsComponent implements OnInit { this.active_form.set('group'); } }); + private _timing_fields_locked = false; + private _sync_timing_fields = effect(() => { + const should_disable = this.is_edit_in_progress(); + if (this._timing_fields_locked === should_disable) return; + this._timing_fields_locked = should_disable; + const action = should_disable ? 'disable' : 'enable'; + this.form.get('date')?.[action]({ emitEvent: false }); + }); public readonly active_form = signal('single'); public readonly form_type_config = signal({ @@ -243,6 +255,17 @@ export class VisitorFlowDetailsComponent implements OnInit { public readonly form_value = toSignal(this.form.valueChanges, { initialValue: this.form.value, }); + public readonly is_edit = computed(() => !!this.form_value()?.id); + public readonly is_edit_in_progress = computed(() => { + if (!this.is_edit()) return false; + const booking_date = Number(this.form_value()?.date || 0); + if (!booking_date) return false; + const duration = Number(this.form_value()?.duration || 0); + const end_date = this.form_value()?.all_day + ? addHours(booking_date, 24).valueOf() + : addMinutes(booking_date, duration).valueOf(); + return Date.now() >= booking_date && Date.now() < end_date; + }); public readonly selected_building_id = computed(() => { const zones = this.form_value()?.zones || []; const building_ids = new Set(this._org.buildings.map((bld) => bld.id)); @@ -303,6 +326,7 @@ export class VisitorFlowDetailsComponent implements OnInit { } 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.form.getRawValue(); 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 index 5718fbf5d14..f002c009322 100644 --- 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 @@ -8,93 +8,95 @@ import { IconComponent, TranslatePipe } from '@placeos/components'; @Component({ selector: 'visitor-flow-recent', template: ` -
    -
    -
    + @if (!is_edit()) { +
    +
    -
    Quick Action
    + class="relative overflow-hidden rounded px-2 py-1 text-sm capitalize" + > +
    +
    Quick Action
    +
    +
    + history +
    Recent Visitors
    +
    -
    - 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. + } +

    -
    -
    -

    - {{ '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
    - - } -
    - } @else { -
    - person_search -
    -
    - No recent visitors -
    -
    - Your visitor history will appear here
    -
    - } -
    + } +
    + } `, styles: [ ` @@ -122,6 +124,7 @@ export class VisitorFlowRecentComponent implements OnInit { initialValue: this._booking_form.form.value, }, ); + public readonly is_edit = computed(() => !!this.form_value()?.id); public readonly is_single = computed(() => { const is_group_mode = this.options()?.group === true; 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 index 23c94f33d2f..bf44f5db7ce 100644 --- 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 @@ -36,7 +36,7 @@ import { VisitorFlowSuccessComponent } from './visitor-flow-success.component'; > info
    - {{ 'BOOKINGS.VISITOR_TIME_HEADER' | translate }} + {{ visit_heading() | translate }}
    @@ -45,10 +45,7 @@ import { VisitorFlowSuccessComponent } from './visitor-flow-success.component'; > info
    - {{ - 'BOOKINGS.VISITOR_DETAILS_HEADER' - | translate - }} + {{ 'BOOKINGS.VISITOR_DETAILS_HEADER' | translate }}
    @@ -147,6 +144,11 @@ export class VisitorFlowNewComponent extends AsyncHandler implements OnInit { public readonly is_multiple = computed( () => !!this.form_value()?.assets?.length, ); + public readonly visit_heading = computed(() => + this.form_value()?.id + ? 'BOOKINGS.EDIT_VISITOR_DETAILS' + : 'BOOKINGS.VISITOR_TIME_HEADER', + ); public ngOnInit() { this._booking_form.form.patchValue({ booking_type: 'visitor' }); 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 00000000000..a712857263a --- /dev/null +++ b/apps/workplace/src/tests/book/visitor-flow-details.component.spec.ts @@ -0,0 +1,101 @@ +import { FormControl, FormGroup } from '@angular/forms'; +import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/jest'; +import { BookingFormService } from '@placeos/bookings'; +import { OrganisationService, SettingsService } from '@placeos/common'; +import { VisitorFlowDetailsComponent } from 'apps/workplace/src/app/book/visitor-flow-new/visitor-flow-details.component'; +import { MockProvider } from 'ng-mocks'; +import { BehaviorSubject } from 'rxjs'; + +describe('VisitorFlowDetailsComponent', () => { + let spectator: SpectatorRouting; + let options$: BehaviorSubject; + let form: FormGroup; + let now = Date.now(); + + const createComponent = createRoutingFactory({ + component: VisitorFlowDetailsComponent, + shallow: true, + providers: [ + MockProvider(OrganisationService, { + active_buildings: new BehaviorSubject([]), + buildings: [{ id: 'bld-1', name: 'Building One' }], + building: { id: 'bld-1', name: 'Building One', timezone: '' }, + } as any), + MockProvider(SettingsService, { + get: jest.fn(), + }), + { + provide: BookingFormService, + useFactory: () => { + options$ = new BehaviorSubject({ + type: 'visitor', + group: false, + }); + form = new FormGroup({ + id: new FormControl(''), + asset_id: new FormControl(''), + asset_name: new FormControl(''), + company: new FormControl(''), + phone: new FormControl(''), + user: new FormControl(null), + user_email: new FormControl(''), + zones: new FormControl([]), + date: new FormControl(Date.now()), + duration: new FormControl(60), + title: new FormControl('Visit'), + all_day: new FormControl(false), + assets: new FormControl([]), + }); + return { + form, + options: options$.asObservable(), + setOptions: jest.fn((options) => + options$.next({ ...options$.value, ...options }), + ), + }; + }, + }, + ], + }); + + beforeEach(() => { + now = Date.now(); + spectator = createComponent(); + }); + + it('should hide visitor type switching when editing an existing booking', () => { + const service = spectator.inject(BookingFormService); + form.patchValue({ + id: 'visitor-booking-1', + asset_id: 'visitor.one@example.com', + asset_name: 'Visitor One', + }); + + spectator.component.ngOnInit(); + spectator.detectChanges(); + (service.setOptions as jest.Mock).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.query('mat-select')).toBeNull(); + 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', () => { + form.patchValue({ + 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.get('date')?.disabled).toBe(true); + expect(form.get('duration')?.disabled).toBe(false); + }); +}); 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 00000000000..984ec5561c6 --- /dev/null +++ b/apps/workplace/src/tests/book/visitor-flow-recent.component.spec.ts @@ -0,0 +1,57 @@ +import { FormControl, FormGroup } from '@angular/forms'; +import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/jest'; +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'; +import { BehaviorSubject } from 'rxjs'; + +describe('VisitorFlowRecentComponent', () => { + let spectator: SpectatorRouting; + let form: FormGroup; + + const createComponent = createRoutingFactory({ + component: VisitorFlowRecentComponent, + shallow: true, + providers: [ + MockProvider( + SettingsService, + { + get: jest.fn(() => []), + } as any, + ), + { + provide: BookingFormService, + useFactory: () => { + form = new FormGroup({ + id: new FormControl(''), + asset_id: new FormControl(''), + assets: new FormControl([]), + }); + return { + form, + options: new BehaviorSubject({ + type: 'visitor', + group: false, + }).asObservable(), + }; + }, + }, + ], + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should hide quick actions when editing an existing booking', () => { + form.patchValue({ 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'); + }); +}); diff --git a/apps/workplace/src/tests/book/visitor-flow.component.spec.ts b/apps/workplace/src/tests/book/visitor-flow.component.spec.ts new file mode 100644 index 00000000000..b18a63d0181 --- /dev/null +++ b/apps/workplace/src/tests/book/visitor-flow.component.spec.ts @@ -0,0 +1,76 @@ +import { FormControl, FormGroup } from '@angular/forms'; +import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/jest'; +import { BookingFormService } from '@placeos/bookings'; +import { SettingsService } from '@placeos/common'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { VisitorFlowNewComponent } from 'apps/workplace/src/app/book/visitor-flow-new/visitor-flow.component'; + +describe('VisitorFlowNewComponent', () => { + let spectator: SpectatorRouting; + let form: FormGroup; + + const createComponent = createRoutingFactory({ + component: VisitorFlowNewComponent, + shallow: true, + providers: [ + { + provide: BookingFormService, + useFactory: () => { + form = new FormGroup({ + id: new FormControl(''), + title: new FormControl('Visit'), + description: new FormControl(''), + asset_name: new FormControl(''), + asset_id: new FormControl(''), + assets: new FormControl([]), + }); + return { + form, + view: () => 'details', + booking: null, + last_count: 0, + setOptions: jest.fn(), + setView: jest.fn(), + loadGroupSiblings: jest.fn(async () => []), + }; + }, + }, + { + provide: SettingsService, + useValue: { + get: jest.fn(() => []), + saveUserSetting: jest.fn(), + }, + }, + { + provide: ActivatedRoute, + useValue: { + paramMap: of({ + has: () => false, + get: () => null, + }), + }, + } as any, + ], + mocks: [], + }); + + beforeEach(() => { + spectator = createComponent({ detectChanges: false }); + }); + + 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', () => { + form.patchValue({ id: 'visitor-booking-3' }); + + expect(spectator.component.visit_heading()).toBe( + 'BOOKINGS.EDIT_VISITOR_DETAILS', + ); + }); +}); diff --git a/libs/bookings/src/lib/booking-form.service.ts b/libs/bookings/src/lib/booking-form.service.ts index 1404715a79f..9e23ff97dbd 100644 --- a/libs/bookings/src/lib/booking-form.service.ts +++ b/libs/bookings/src/lib/booking-form.service.ts @@ -419,7 +419,9 @@ export class BookingFormService extends AsyncHandler { { ...booking.extension_data, ...booking, - _in_progress: booking.state === 'started', + _in_progress: + booking.state === 'started' || + booking.state === 'in_progress', }, [null, undefined, ''], ), diff --git a/libs/bookings/src/lib/booking.utilities.ts b/libs/bookings/src/lib/booking.utilities.ts index a856c860589..5848762ffee 100644 --- a/libs/bookings/src/lib/booking.utilities.ts +++ b/libs/bookings/src/lib/booking.utilities.ts @@ -134,7 +134,9 @@ export function generateBookingForm(booking: Booking = new Booking()) { update_master: new FormControl(false), self_registered: new FormControl(false), is_assgined: new FormControl(false), - _in_progress: new FormControl(booking.state === 'started'), + _in_progress: new FormControl( + booking.state === 'started' || booking.state === 'in_progress', + ), }); form.controls.user.valueChanges.subscribe((user) => { if (!user) return; @@ -226,7 +228,9 @@ export function generateBookingForm(booking: Booking = new Booking()) { form.get('date')?.enable({ emitEvent: false }); } }); - if (booking.state === 'started') form.get('date').disable(); + if (booking.state === 'started' || booking.state === 'in_progress') { + form.get('date').disable(); + } return form; } diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index d5c39c8f952..003890f6856 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -743,7 +743,7 @@ "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", From 3de27d2a524136a749ef1c741929c03c99d4a17a Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 9 Mar 2026 16:55:45 +1100 Subject: [PATCH 182/392] chore(workplace): fix selected building when editing visitors --- .../visitor-flow-details.component.ts | 41 +++++++++----- .../visitor-flow-details.component.spec.ts | 53 ++++++++++++++++--- 2 files changed, 75 insertions(+), 19 deletions(-) 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 index 924dbd970c7..6bc51409f3a 100644 --- 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 @@ -103,11 +103,7 @@ type VisitorFormType = 'single' | 'group'; = booking_date && Date.now() < end_date; }); public readonly selected_building_id = computed(() => { - const zones = this.form_value()?.zones || []; - const building_ids = new Set(this._org.buildings.map((bld) => bld.id)); - return ( - zones.find((zone_id) => building_ids.has(zone_id)) || - this._org.building?.id || - zones[0] || - '' - ); + return this._resolveSelectedBuildingId(this.form_value()?.zones || []); }); public readonly max_duration = computed(() => @@ -323,6 +312,7 @@ export class VisitorFlowDetailsComponent implements OnInit { this.form.patchValue({ zones: [this._org.building.id] }); return; } + this._syncBuildingContext(); } public setActiveForm(form: VisitorFormType) { @@ -378,4 +368,29 @@ export class VisitorFlowDetailsComponent implements OnInit { }); } } + + public setBuilding(building_id: string) { + this._setActiveBuilding(building_id); + this.form.patchValue({ zones: building_id ? [building_id] : [] }); + } + + 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] || ''; + } + + private _syncBuildingContext() { + const building_id = this.selected_building_id(); + if (!building_id || this._org.building?.id === building_id) return; + this._setActiveBuilding(building_id); + } + + private _setActiveBuilding(building_id: string) { + const building = this._org.find(building_id); + if (!building) return; + this._org.setBuilding(building); + } } 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 index a712857263a..5afafb8c821 100644 --- a/apps/workplace/src/tests/book/visitor-flow-details.component.spec.ts +++ b/apps/workplace/src/tests/book/visitor-flow-details.component.spec.ts @@ -11,19 +11,45 @@ describe('VisitorFlowDetailsComponent', () => { let options$: BehaviorSubject; let form: FormGroup; let now = Date.now(); + let set_building: jest.Mock; + let find_building: jest.Mock; const createComponent = createRoutingFactory({ component: VisitorFlowDetailsComponent, shallow: true, providers: [ - MockProvider(OrganisationService, { - active_buildings: new BehaviorSubject([]), - buildings: [{ id: 'bld-1', name: 'Building One' }], - building: { id: 'bld-1', name: 'Building One', timezone: '' }, - } as any), MockProvider(SettingsService, { get: jest.fn(), }), + { + provide: OrganisationService, + useFactory: () => { + const building_list = [ + { id: 'bld-1', name: 'Building One', timezone: '' }, + { id: 'bld-2', name: 'Building Two', timezone: '' }, + ]; + find_building = jest.fn((id: string) => + building_list.find((building) => building.id === id), + ); + set_building = jest.fn(); + return { + active_buildings: new BehaviorSubject(building_list), + buildings: building_list, + building: building_list[0], + find: find_building, + setBuilding: set_building, + levelWithID: jest.fn((ids: string[]) => + ids?.includes('lvl-2') + ? { + id: 'lvl-2', + parent_id: 'bld-2', + name: 'Level Two', + } + : null, + ), + } as any; + }, + }, { provide: BookingFormService, useFactory: () => { @@ -79,7 +105,6 @@ describe('VisitorFlowDetailsComponent', () => { expect(spectator.component.is_edit()).toBe(true); expect(spectator.component.active_form()).toBe('single'); expect(service.setOptions).not.toHaveBeenCalled(); - expect(spectator.query('mat-select')).toBeNull(); expect(spectator.element).not.toHaveText('BOOKINGS.VISITOR_SINGLE'); expect(spectator.element).not.toHaveText('BOOKINGS.VISITOR_MULTIPLE'); }); @@ -98,4 +123,20 @@ describe('VisitorFlowDetailsComponent', () => { expect(form.get('date')?.disabled).toBe(true); expect(form.get('duration')?.disabled).toBe(false); }); + + it('should resolve the selected building from a level zone when editing', () => { + form.patchValue({ + id: 'visitor-booking-3', + zones: ['org-1', 'lvl-2'], + }); + + spectator.component.ngOnInit(); + spectator.detectChanges(); + + expect(spectator.component.selected_building_id()).toBe('bld-2'); + expect(find_building).toHaveBeenCalledWith('bld-2'); + expect(set_building).toHaveBeenCalledWith( + expect.objectContaining({ id: 'bld-2' }), + ); + }); }); From 2e622f13e4b2996353fd393b5eb50bcf49b89ce6 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Tue, 10 Mar 2026 12:12:05 +1100 Subject: [PATCH 183/392] fix(workplace): fix selecting desk from favourites to book (PPT-2404) --- .../desk-flow-select-list.component.ts | 7 +- .../desk-flow-select-map.component.ts | 7 +- .../desk-flow-select.component.ts | 19 +++- .../book/desk-flow-new/desk-flow.component.ts | 29 ++++++ .../landing-favourites-new.component.ts | 22 +---- .../book/desk-flow-new.component.spec.ts | 79 +++++++++++++++ .../desk-flow-select-list.component.spec.ts | 52 ++++++++++ .../landing-favourites-new.component.spec.ts | 97 +++++++++++++++++++ 8 files changed, 285 insertions(+), 27 deletions(-) create mode 100644 apps/workplace/src/tests/book/desk-flow-new.component.spec.ts create mode 100644 apps/workplace/src/tests/book/desk-flow-select-list.component.spec.ts create mode 100644 apps/workplace/src/tests/landing/landing-favourites-new.component.spec.ts 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 index bd6ae2d9aee..c36c6b6a9db 100644 --- 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 @@ -236,21 +236,20 @@ export class DeskFlowSelectListComponent { }, ); - // Include currently booked resource in edit mode + // Keep the active desk visible even if it falls outside the current + // availability result set while the form is loading or filters are updating. public readonly available_items = computed(() => { const available = this._available_items(); const form = this.form_value(); const resources = form.resources || []; - // If in edit mode and we have a resource selected, ensure it's in the list - if (form.id && resources.length > 0) { + 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) { - // Add the currently booked desk(s) to the available list return [...missing_resources, ...available]; } } 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 index 7edc4b59530..437b2da82b4 100644 --- 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 @@ -122,21 +122,20 @@ export class DeskFlowSelectMapComponent extends AsyncHandler implements OnInit { { initialValue: this._booking_form.form.value }, ); - // Include currently booked resource in edit mode + // 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 in edit mode and we have a resource selected, ensure it's in the list - if (form.id && resources.length > 0) { + 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) { - // Add the currently booked desk(s) to the available list return [...missing_resources, ...available]; } } 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 index b9eeada68aa..b22328904f8 100644 --- 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 @@ -1,5 +1,12 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { + Component, + computed, + effect, + inject, + OnInit, + signal, +} from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; @@ -708,6 +715,16 @@ export class DeskFlowSelectComponent extends AsyncHandler implements OnInit { public readonly active = signal(''); + constructor() { + super(); + effect(() => { + const selected_ids = (this.form_value()?.resources || []).map( + ({ id }) => id, + ); + this.selected.set(selected_ids); + }); + } + public readonly levels = combineLatest([ this._org.active_region, this._org.active_building, 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 index cbd248f3bc9..ea1e4797463 100644 --- 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 @@ -11,6 +11,7 @@ import { notifySuccess, } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; +import { firstValueFrom } from 'rxjs'; import { DeskFlowAutoAssignComponent } from './desk-flow-auto-assign.component'; import { DeskFlowDetailsComponent } from './desk-flow-details.component'; import { DeskFlowSelectComponent } from './desk-flow-select.component'; @@ -150,6 +151,34 @@ export class DeskFlowNewComponent extends AsyncHandler implements OnInit { this._booking_form.setView(param.get('step') as any); }), ); + this.subscription( + 'route.query', + this._route.queryParamMap.subscribe(async (params) => { + if (!params.has('asset_id')) return; + const asset_id = params.get('asset_id'); + const form = this._booking_form.form.getRawValue(); + if ( + asset_id === form.asset_id && + (form.resources || []).some(({ id }) => id === asset_id) + ) { + return; + } + const resources = await firstValueFrom( + this._booking_form.resources, + ); + const resource = resources.find((item) => item.id === asset_id); + if (!resource) return; + this._booking_form.setOptions({ + type: 'desk', + ...(resource.zone?.id ? { zones: [resource.zone.id] } : {}), + }); + this._booking_form.form.patchValue({ + booking_type: 'desk', + resources: [resource], + asset_id: resource.id, + }); + }), + ); } public async confirmBooking() { 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 index ee6665a8bcc..9948f14c8cd 100644 --- a/apps/workplace/src/app/landing-new/landing-favourites-new.component.ts +++ b/apps/workplace/src/app/landing-new/landing-favourites-new.component.ts @@ -345,24 +345,10 @@ export class LandingFavouritesNewComponent { break; case 'desk': - const desk = this.all_desks().find((d) => d.id === item.id); - if (!desk) return; - - // Initialize form with desk before navigating - this._booking_form.newForm('desk'); - this._booking_form.setOptions({ type: 'desk' }); - this._booking_form.form.patchValue({ - resources: [desk], - asset_id: desk.id, - booking_type: 'desk', - }); - - // Navigate after setting the resource - if (use_new_features) { - this._router.navigate(['/book', 'desk']); - } else { - this._router.navigate(['/book', 'desks']); - } + this._router.navigate( + ['/book', use_new_features ? 'desk' : 'desks'], + { queryParams: { asset_id: item.id } }, + ); break; case 'parking': 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 00000000000..25685da925c --- /dev/null +++ b/apps/workplace/src/tests/book/desk-flow-new.component.spec.ts @@ -0,0 +1,79 @@ +import { FormControl, FormGroup } from '@angular/forms'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { ActivatedRoute } from '@angular/router'; +import { BookingFormService } from '@placeos/bookings'; +import { MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; +import { signal } from '@angular/core'; +import { DeskFlowNewComponent } from '../../app/book/desk-flow-new/desk-flow.component'; + +describe('DeskFlowNewComponent', () => { + let spectator: Spectator; + let form: FormGroup; + + const createComponent = createComponentFactory({ + component: DeskFlowNewComponent, + detectChanges: false, + shallow: true, + providers: [ + MockProvider(BookingFormService, { + setView: jest.fn(), + setOptions: jest.fn(), + postForm: jest.fn(), + postFormForGroup: jest.fn(), + view: signal('form'), + options: of({ type: 'desk' }), + resources: of([ + { + id: 'desk-123', + name: 'Desk 123', + zone: { id: 'level-1' }, + }, + ]), + form: (() => { + form = new FormGroup({ + booking_type: new FormControl('desk'), + resources: new FormControl([]), + asset_id: new FormControl(''), + }); + return form; + })(), + } as any), + MockProvider(ActivatedRoute, { + paramMap: of({ + has: (key: string) => key === 'step', + get: (key: string) => (key === 'step' ? 'form' : null), + }), + queryParamMap: of({ + has: (key: string) => key === 'asset_id', + get: (key: string) => + key === 'asset_id' ? 'desk-123' : null, + }), + } as any), + ], + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should hydrate the selected desk from the asset_id query param', async () => { + await spectator.component.ngOnInit(); + await Promise.resolve(); + + expect(spectator.inject(BookingFormService).setOptions).toHaveBeenCalledWith( + { type: 'desk', zones: ['level-1'] }, + ); + expect(form.getRawValue()).toEqual({ + booking_type: 'desk', + resources: [ + { + id: 'desk-123', + name: 'Desk 123', + zone: { id: 'level-1' }, + }, + ], + asset_id: 'desk-123', + }); + }); +}); 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 00000000000..e8162d83763 --- /dev/null +++ b/apps/workplace/src/tests/book/desk-flow-select-list.component.spec.ts @@ -0,0 +1,52 @@ +import { FormControl, FormGroup } from '@angular/forms'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { BookingFormService } from '@placeos/bookings'; +import { SettingsService } from '@placeos/common'; +import { MockProvider } from 'ng-mocks'; +import { BehaviorSubject } from 'rxjs'; +import { DeskFlowSelectListComponent } from '../../app/book/desk-flow-new/desk-flow-select-list.component'; + +describe('DeskFlowSelectListComponent', () => { + let spectator: Spectator; + let form: FormGroup; + let available_resources: BehaviorSubject; + + const createComponent = createComponentFactory({ + component: DeskFlowSelectListComponent, + detectChanges: false, + shallow: true, + providers: [ + MockProvider(BookingFormService, { + loading: new BehaviorSubject(''), + available_resources: (() => { + available_resources = new BehaviorSubject([]); + return available_resources.asObservable(); + })(), + form: (() => { + form = new FormGroup({ + resources: new FormControl([]), + }); + return form; + })(), + } as any), + MockProvider(SettingsService, { + get: jest.fn(), + saveUserSetting: jest.fn(), + }), + ], + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should keep the selected desk in the rendered list for new bookings', () => { + form.patchValue({ + resources: [{ id: 'desk-123', name: 'Desk 123' }], + }); + + expect(spectator.component.available_items()).toEqual([ + { id: 'desk-123', name: 'Desk 123' }, + ]); + }); +}); 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 00000000000..9006b5232d2 --- /dev/null +++ b/apps/workplace/src/tests/landing/landing-favourites-new.component.spec.ts @@ -0,0 +1,97 @@ +import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { Router } from '@angular/router'; +import { BookingFormService } from '@placeos/bookings'; +import { OrganisationService, SettingsService } from '@placeos/common'; +import { EventFormService } from '@placeos/events'; +import { ExploreSpacesService } from '@placeos/explore'; +import { MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; +import { LandingFavouritesNewComponent } from '../../app/landing-new/landing-favourites-new.component'; + +describe('LandingFavouritesNewComponent', () => { + let spectator: Spectator; + const createComponent = createComponentFactory({ + component: LandingFavouritesNewComponent, + detectChanges: false, + componentProviders: [MockProvider(ExploreSpacesService)], + providers: [ + MockProvider(OrganisationService, { + organisation: { id: 'org-1' }, + active_building: of({ id: 'bld-1' }), + } as any), + MockProvider(SettingsService, { + get: jest.fn(), + saveUserSetting: jest.fn(), + }), + MockProvider(EventFormService, { + spaces$: of([]), + newForm: jest.fn(), + form: { patchValue: jest.fn() }, + } as any), + MockProvider(BookingFormService, { + loadResourceList: jest.fn((type: string) => + of( + type === 'parking-spaces' + ? [{ id: 'park-123', name: 'Parking 123', zones: [] }] + : [], + ), + ), + newForm: jest.fn(), + setOptions: jest.fn(), + form: { patchValue: jest.fn() }, + } as any), + MockProvider(Router, { navigate: jest.fn() }), + ], + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + 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 jest.Mock).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_form.form.patchValue).not.toHaveBeenCalled(); + }); + + it('should still preload non-desk favourites into the booking form', () => { + const router = spectator.inject(Router); + const settings = spectator.inject(SettingsService); + const booking_form = spectator.inject(BookingFormService); + (settings.get as jest.Mock).mockReturnValue(true); + const parking = { + id: 'park-123', + name: 'Parking 123', + zones: [], + type: 'parking', + } as any; + + 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_form.form.patchValue).toHaveBeenCalledWith({ + resources: [{ id: 'park-123', name: 'Parking 123', zones: [] }], + asset_id: 'park-123', + booking_type: 'parking', + }); + }); +}); From c4d3f2c67fb5ffa55afc3a69d150f93f71aab8a3 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 11 Mar 2026 11:07:12 +1100 Subject: [PATCH 184/392] feat(concierge): add homebase field to desks (PPT-2413) --- .../src/app/desks/desk-modal.component.ts | 14 ++++++++ .../src/app/desks/desks-manage.component.ts | 4 +++ .../src/app/desks/desks-topbar.component.ts | 1 + .../src/app/desks/desks.component.ts | 1 + .../tests/desks/desks-state.service.spec.ts | 36 +++++++++++++++++++ libs/common/src/lib/types/desk.class.ts | 3 ++ libs/common/src/tests/desk.class.spec.ts | 4 +++ shared/assets/locale/en-AU.json | 1 + 8 files changed, 64 insertions(+) diff --git a/apps/concierge/src/app/desks/desk-modal.component.ts b/apps/concierge/src/app/desks/desk-modal.component.ts index 4d27f658573..cf3471ac85b 100644 --- a/apps/concierge/src/app/desks/desk-modal.component.ts +++ b/apps/concierge/src/app/desks/desk-modal.component.ts @@ -202,6 +202,19 @@ const CHARS = '0123456789ABCDEF'; formControlName="notes" > + + + + @@ -280,6 +293,7 @@ export class DeskModalComponent implements OnInit { assigned_user: new FormControl(null), assigned_to: new FormControl(''), assigned_name: new FormControl(''), + homebase: new FormControl(''), security: new FormControl(''), }); diff --git a/apps/concierge/src/app/desks/desks-manage.component.ts b/apps/concierge/src/app/desks/desks-manage.component.ts index 140b321ea55..7c85a83dda9 100644 --- a/apps/concierge/src/app/desks/desks-manage.component.ts +++ b/apps/concierge/src/app/desks/desks-manage.component.ts @@ -69,6 +69,10 @@ const QR_CODES = {}; 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-topbar.component.ts b/apps/concierge/src/app/desks/desks-topbar.component.ts index 96919400723..3430816b504 100644 --- a/apps/concierge/src/app/desks/desks-topbar.component.ts +++ b/apps/concierge/src/app/desks/desks-topbar.component.ts @@ -280,6 +280,7 @@ export class DesksTopbarComponent extends AsyncHandler implements OnInit { bookable: true, groups: ['test-desk-group', 'desk-bookers'], features: ['Standing Desk', 'Dual Monitor'], + homebase: 'Sydney HQ', }).toJSON(); delete desk.images; const data = jsonToCsv([desk]); diff --git a/apps/concierge/src/app/desks/desks.component.ts b/apps/concierge/src/app/desks/desks.component.ts index 0b45af26428..e1348e9b506 100644 --- a/apps/concierge/src/app/desks/desks.component.ts +++ b/apps/concierge/src/app/desks/desks.component.ts @@ -435,6 +435,7 @@ export class DesksComponent extends AsyncHandler implements OnInit, OnDestroy { bookable: true, groups: ['test-desk-group', 'desk-bookers'], features: ['Standing Desk', 'Dual Monitor'], + homebase: 'Sydney HQ', }).toJSON(); delete desk.images; const data = jsonToCsv([desk]); diff --git a/apps/concierge/src/tests/desks/desks-state.service.spec.ts b/apps/concierge/src/tests/desks/desks-state.service.spec.ts index 4723ee78db2..e674ce16af2 100644 --- a/apps/concierge/src/tests/desks/desks-state.service.spec.ts +++ b/apps/concierge/src/tests/desks/desks-state.service.spec.ts @@ -163,6 +163,42 @@ describe('DesksStateService', () => { expect(booking_mod.saveBooking).toHaveBeenCalled(); }); + it('should persist homebase when editing desks', async () => { + jest.spyOn(ts_client_mod, 'updateMetadata').mockReturnValue( + of({}) as any, + ); + const dialog_ref = { + afterClosed: () => + of({ + reason: 'done', + metadata: { + id: 'desk-1', + name: 'Desk 1', + map_id: 'desk-1', + homebase: 'Sydney HQ', + }, + }), + componentInstance: { + event: new EventEmitter(), + loading: { set: jest.fn() }, + }, + close: jest.fn(), + }; + (spectator.inject(MatDialog).open as any).mockReturnValue(dialog_ref); + spectator.service.setFilters({ zones: ['level-1'] }); + + await spectator.service.editDesk({ id: 'desk-1' } as any); + + expect(ts_client_mod.updateMetadata).toHaveBeenCalledWith( + 'level-1', + expect.objectContaining({ + details: expect.arrayContaining([ + expect.objectContaining({ homebase: 'Sydney HQ' }), + ]), + }), + ); + }); + it.todo('should handle loading desk bookings'); it.todo('should handle loading desk list'); it.todo('should handle filtering of desk bookings'); diff --git a/libs/common/src/lib/types/desk.class.ts b/libs/common/src/lib/types/desk.class.ts index 914ff8e8133..d853d3f7990 100644 --- a/libs/common/src/lib/types/desk.class.ts +++ b/libs/common/src/lib/types/desk.class.ts @@ -23,6 +23,8 @@ export class Desk implements Record { public readonly features: string[]; /** List of URLs to images */ public readonly images: string[]; + /** Homebase associated with the desk */ + public readonly homebase: string; /** */ public readonly security: string; @@ -39,6 +41,7 @@ export class Desk implements Record { this.qr_code = data.qr_code || ''; this.features = data.features || []; this.images = data.images || []; + this.homebase = data.homebase || ''; this.security = data.security || ''; for (const key in data) { if (!(key in this)) this[key] = data[key]; diff --git a/libs/common/src/tests/desk.class.spec.ts b/libs/common/src/tests/desk.class.spec.ts index 80e9e7c7b16..b63c4e22a9c 100644 --- a/libs/common/src/tests/desk.class.spec.ts +++ b/libs/common/src/tests/desk.class.spec.ts @@ -13,12 +13,14 @@ describe('Desk', () => { expect(item.zone).toBeInstanceOf(PlaceZone); expect(item.zone?.id).toBe(''); expect(item.groups).toEqual([]); + expect(item.homebase).toBe(''); item = new Desk({ id: 'desk-1', name: 'Some Desk', bookable: true, zone: new PlaceZone({ id: 'zone-1' }), groups: ['Admin'], + homebase: 'Sydney HQ', }); expect(item.id).toBe('desk-1'); expect(item.name).toBe('Some Desk'); @@ -26,6 +28,7 @@ describe('Desk', () => { expect(item.zone).toBeInstanceOf(PlaceZone); expect(item.zone?.id).toBe('zone-1'); expect(item.groups).toEqual(['Admin']); + expect(item.homebase).toBe('Sydney HQ'); }); it('should allow getting simplified version of desk data', () => { @@ -35,5 +38,6 @@ describe('Desk', () => { expect(obj.name).toBe(''); expect(obj.bookable).toBe(false); expect(obj.groups).toEqual([]); + expect(obj.homebase).toBe(''); }); }); diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 6c658c0ae76..1a332828f18 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -1280,6 +1280,7 @@ "DESKS_BOOKING_DELETE_SERIES_CONTENT": "Are you sure you want to delete the entire recurring desk booking series?", "DESKS_BOOKING_DELETE_SERIES_LOADING": "Deleting desk booking series...", "DESKS_SECURITY": "Security Group", + "DESKS_HOMEBASE": "Homebase", "BOOKING_DELETED": "Cancelled", "BOOKING_ENDED": "Manually Ended", "BOOKING_EXPIRED": "Expired", From 6e9505a4ab7ce4750197afb7e13d643ea9c54f94 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 11 Mar 2026 11:26:41 +1100 Subject: [PATCH 185/392] chore(bookings): tweak editing of visitor bookings (PPT-2333) --- .../src/lib/booking-card.component.ts | 2 +- .../lib/booking-details-modal.component.ts | 2 +- libs/bookings/src/lib/booking-form.service.ts | 32 +++-- libs/bookings/src/lib/booking.utilities.ts | 6 +- .../src/test/booking-form.service.spec.ts | 120 ++++++++++++++++++ libs/bookings/src/test/booking.class.spec.ts | 21 +++ .../src/test/booking.utilities.spec.ts | 14 ++ libs/common/src/lib/types/booking.class.ts | 20 ++- 8 files changed, 200 insertions(+), 17 deletions(-) diff --git a/libs/bookings/src/lib/booking-card.component.ts b/libs/bookings/src/lib/booking-card.component.ts index b218f2fee12..68ef5b49bf2 100644 --- a/libs/bookings/src/lib/booking-card.component.ts +++ b/libs/bookings/src/lib/booking-card.component.ts @@ -365,7 +365,7 @@ export class BookingCardComponent if (group_member_name) return group_member_name; const attendee_name = this._visitorAttendeeName(booking); if (attendee_name) return attendee_name; - const asset_name = `${booking?.asset_name || ''}`.trim(); + const asset_name = `${booking?.extension_data?.visitor_name || booking?.asset_name || ''}`.trim(); const reason_values = [ `${booking?.title || ''}`.trim().toLowerCase(), `${booking?.description || ''}`.trim().toLowerCase(), diff --git a/libs/bookings/src/lib/booking-details-modal.component.ts b/libs/bookings/src/lib/booking-details-modal.component.ts index 3335cf9accc..50eb4468216 100644 --- a/libs/bookings/src/lib/booking-details-modal.component.ts +++ b/libs/bookings/src/lib/booking-details-modal.component.ts @@ -691,7 +691,7 @@ export class BookingDetailsModalComponent { if (group_member_name) return group_member_name; const attendee_name = this._visitorAttendeeName(booking); if (attendee_name) return attendee_name; - const asset_name = `${booking.asset_name || ''}`.trim(); + const asset_name = `${booking.extension_data?.visitor_name || booking.asset_name || ''}`.trim(); const reason_values = [ `${booking.title || ''}`.trim().toLowerCase(), `${booking.description || ''}`.trim().toLowerCase(), diff --git a/libs/bookings/src/lib/booking-form.service.ts b/libs/bookings/src/lib/booking-form.service.ts index 9e23ff97dbd..0a760c66842 100644 --- a/libs/bookings/src/lib/booking-form.service.ts +++ b/libs/bookings/src/lib/booking-form.service.ts @@ -646,7 +646,7 @@ export class BookingFormService extends AsyncHandler { details.close(); } - public async postForm(ignore_check = false) { + public async postForm(ignore_check = false, reset_form = true) { if (!this.form) throw 'No form for booking'; if (!this.form.valid) { const invalid_fields = getInvalidFields( @@ -794,7 +794,11 @@ export class BookingFormService extends AsyncHandler { phone: value.phone, company: value.company, ...(value.booking_type === 'visitor' - ? { international: !!value.international } + ? { + international: !!value.international, + visitor_name: + value.asset_name || value.asset_id || '', + } : {}), ...(group_members.length ? { @@ -841,14 +845,16 @@ export class BookingFormService extends AsyncHandler { } this._loading.next(''); const { booking_type } = value; - this.clearForm(); - this.form?.patchValue({ booking_type }); + if (reset_form) { + this.clearForm(); + this.form?.patchValue({ booking_type }); + } this.last_success = result; sessionStorage.setItem( 'PLACEOS.last_booked_booking', JSON.stringify(result), ); - this.setView('success'); + if (reset_form) this.setView('success'); return result; } @@ -960,7 +966,7 @@ export class BookingFormService extends AsyncHandler { : [this._org.organisation.id, this._org.region?.id] ).filter((_) => _), }); - const bkn = await this.postForm(true).catch((error) => { + const bkn = await this.postForm(true, false).catch((error) => { throw `${user.name || user.email}: ${this._error_message(error)}`; }); if (bkn?.id) booking_ids.push(bkn.id); @@ -983,6 +989,9 @@ export class BookingFormService extends AsyncHandler { } throw this._error_message(error); } + this.clearForm(); + this.form?.patchValue({ booking_type: type }); + this.setView('success'); return user_booking; } @@ -1031,7 +1040,7 @@ export class BookingFormService extends AsyncHandler { }), ], }); - const bkn = await this.postForm(true).catch((error) => { + const bkn = await this.postForm(true, false).catch((error) => { throw `${visitor.name || visitor.email}: ${this._error_message(error)}`; }); if (bkn?.id) booking_ids.push(bkn.id); @@ -1046,6 +1055,9 @@ export class BookingFormService extends AsyncHandler { } throw this._error_message(error); } + this.clearForm(); + this.form?.patchValue({ booking_type: 'visitor' }); + this.setView('success'); return first_booking; } @@ -1164,12 +1176,15 @@ export class BookingFormService extends AsyncHandler { : {}), }); } - const bkn = await this.postForm(true); + const bkn = await this.postForm(true, false); if (!first_result) first_result = bkn; } } catch (error) { throw this._error_message(error); } + this.clearForm(); + this.form?.patchValue({ booking_type: type }); + this.setView('success'); return first_result; } @@ -1249,6 +1264,7 @@ export class BookingFormService extends AsyncHandler { ? new User({ name: group_member?.name || + booking.extension_data?.visitor_name || booking.asset_name || booking.asset_id, email: booking.asset_id, diff --git a/libs/bookings/src/lib/booking.utilities.ts b/libs/bookings/src/lib/booking.utilities.ts index 2e91d02913c..1532a29124b 100644 --- a/libs/bookings/src/lib/booking.utilities.ts +++ b/libs/bookings/src/lib/booking.utilities.ts @@ -49,6 +49,10 @@ function setBookingAsset(form: FormGroup, resource: any) { } export function generateBookingForm(booking: Booking = new Booking()) { + const visitor_name = + booking.booking_type === 'visitor' + ? booking.extension_data?.visitor_name || booking.asset_name || '' + : booking.asset_name || booking.description; const form = new FormGroup({ id: new FormControl(booking.id || ''), parent_id: new FormControl(booking.parent_id || ''), @@ -69,7 +73,7 @@ export function generateBookingForm(booking: Booking = new Booking()) { resources: new FormControl([]), company: new FormControl(booking.extension_data?.company || ''), asset_id: new FormControl(booking.asset_id, [Validators.required]), - asset_name: new FormControl(booking.asset_name || booking.description), + asset_name: new FormControl(visitor_name), assets: new FormControl(booking.extension_data?.assets || []), attendees: new FormControl(booking.attendees || []), map_id: new FormControl(booking.extension_data?.map_id), diff --git a/libs/bookings/src/test/booking-form.service.spec.ts b/libs/bookings/src/test/booking-form.service.spec.ts index 849de7c6cfb..ae2044bd197 100644 --- a/libs/bookings/src/test/booking-form.service.spec.ts +++ b/libs/bookings/src/test/booking-form.service.spec.ts @@ -8,6 +8,7 @@ import { BehaviorSubject, of, Subject } from 'rxjs'; import { Booking, OrganisationService } from '@placeos/common'; import { SettingsService } from 'libs/common/src/lib/settings.service'; import { BookingFormService } from '../lib/booking-form.service'; +import * as booking_mod from '../lib/bookings.fn'; jest.mock('@placeos/ts-client'); jest.mock('libs/bookings/src/lib/bookings.fn'); @@ -29,6 +30,7 @@ describe('BookingFormService', () => { MockProvider(OrganisationService, { initialised: of(true), active_building: new BehaviorSubject({ id: 'bld-1' }), + organisation: { id: 'org-1' }, building: { id: 'bld-1' }, } as any), MockProvider(MatDialog, { open: jest.fn() }), @@ -174,4 +176,122 @@ describe('BookingFormService', () => { jest.useRealTimers(); }); + it('should store visitor_name in extension data when saving visitor bookings', async () => { + const save_booking = booking_mod.saveBooking as jest.Mock; + (spectator.inject(PaymentsService) as any).enabled = false; + save_booking.mockReset(); + save_booking.mockImplementation((booking: Booking) => of(booking)); + spectator.service.newForm( + 'visitor', + new Booking({ + booking_type: 'visitor', + date: Date.now() + 60 * 60 * 1000, + duration: 60, + asset_id: 'visitor@example.com', + }), + ); + spectator.service.form.patchValue({ + booking_type: 'visitor', + asset_id: 'visitor@example.com', + asset_name: 'Visitor One', + title: 'Vendor Interview', + }); + + await spectator.service.postForm(true); + + expect(save_booking).toHaveBeenCalledTimes(1); + expect( + (save_booking.mock.calls[0][0] as Booking).extension_data + .visitor_name, + ).toBe('Visitor One'); + expect((save_booking.mock.calls[0][0] as Booking).description).toBe( + 'Vendor Interview', + ); + }); + + it('should only clear grouped visitor edit form after all siblings are saved', async () => { + const clear_form = jest.spyOn(spectator.service, 'clearForm'); + const saved_forms: { + asset_id: string; + zones: string[]; + location: string; + }[] = []; + const post_form = jest + .spyOn(spectator.service, 'postForm') + .mockImplementation(async () => { + const value = spectator.service.form.getRawValue(); + saved_forms.push({ + asset_id: value.asset_id, + zones: [...(value.zones || [])], + location: value.location, + }); + return new Booking({ + ...value, + id: + value.asset_id === 'visitor.one@example.com' + ? 'booking-parent' + : 'booking-child', + }); + }); + spectator.service.newForm( + 'visitor', + new Booking({ + id: 'booking-parent', + booking_type: 'visitor', + date: Date.now() + 60 * 60 * 1000, + duration: 60, + zones: ['org-1', 'bld-1'], + extension_data: { location: 'Main Lobby' }, + }), + ); + spectator.service.form.patchValue({ + id: 'booking-parent', + booking_type: 'visitor', + title: 'Vendor Visit', + asset_id: 'visitor.one@example.com', + asset_name: 'Visitor One', + zones: ['org-1', 'bld-1'], + location: 'Main Lobby', + }); + spectator.service.setOptions({ + type: 'visitor', + group: true, + members: [ + { + name: 'Visitor One', + email: 'visitor.one@example.com', + } as any, + { + name: 'Visitor Two', + email: 'visitor.two@example.com', + } as any, + ], + }); + clear_form.mockClear(); + + await spectator.service.editFormForGroup([ + new Booking({ + id: 'booking-parent', + asset_id: 'visitor.one@example.com', + booking_type: 'visitor', + }), + new Booking({ + id: 'booking-child', + parent_id: 'booking-parent', + asset_id: 'visitor.two@example.com', + booking_type: 'visitor', + }), + ]); + + expect(post_form).toHaveBeenCalledTimes(2); + 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[1].location).toBe('Main Lobby'); + }); + }); diff --git a/libs/bookings/src/test/booking.class.spec.ts b/libs/bookings/src/test/booking.class.spec.ts index 05c4f4854c1..181eab0caa9 100644 --- a/libs/bookings/src/test/booking.class.spec.ts +++ b/libs/bookings/src/test/booking.class.spec.ts @@ -37,4 +37,25 @@ describe('Booking class', () => { }); expect(booking.is_done).toBeTruthy(); }); + + it('should prefer visitor name from extension data', () => { + booking = new Booking({ + booking_type: 'visitor', + asset_id: 'visitor@example.com', + description: 'Vendor Interview', + extension_data: { visitor_name: 'Visitor One' }, + }); + + expect(booking.asset_name).toBe('Visitor One'); + }); + + it('should not use visitor reason as fallback visitor name', () => { + booking = new Booking({ + booking_type: 'visitor', + asset_id: 'visitor@example.com', + description: 'Vendor Interview', + }); + + expect(booking.asset_name).toBe('visitor@example.com'); + }); }); diff --git a/libs/bookings/src/test/booking.utilities.spec.ts b/libs/bookings/src/test/booking.utilities.spec.ts index f1b50081a70..87f2f0421a5 100644 --- a/libs/bookings/src/test/booking.utilities.spec.ts +++ b/libs/bookings/src/test/booking.utilities.spec.ts @@ -1,9 +1,23 @@ import { FormGroup } from '@angular/forms'; +import { Booking } from '@placeos/common'; import { generateBookingForm } from '../lib/booking.utilities'; describe('Booking Utilities', () => { describe('generateBookingForm', () => { it('should get current status from a list of bookings', () => expect(generateBookingForm()).toBeInstanceOf(FormGroup)); + + it('should use visitor_name extension data instead of description for visitor forms', () => { + const form = generateBookingForm( + new Booking({ + booking_type: 'visitor', + asset_id: 'visitor@example.com', + description: 'Vendor Interview', + extension_data: { visitor_name: 'Visitor One' }, + }), + ); + + expect(form.value.asset_name).toBe('Visitor One'); + }); }); }); diff --git a/libs/common/src/lib/types/booking.class.ts b/libs/common/src/lib/types/booking.class.ts index ef12ac4098f..c3bc05c8b41 100644 --- a/libs/common/src/lib/types/booking.class.ts +++ b/libs/common/src/lib/types/booking.class.ts @@ -211,13 +211,21 @@ export class Booking { this.parent_id = data.parent_id || ''; this.asset_id = data.asset_id || ''; this.asset_ids = data.asset_ids || [data.asset_id].filter((_) => _); + const booking_type = data.booking_type || data.type || ' '; this.asset_name = - data.asset_name || - data.extension_data?.asset_name || - data.extension_data?.name || - data.description || - data.asset_id || - ''; + booking_type === 'visitor' + ? data.extension_data?.visitor_name || + data.asset_name || + data.extension_data?.asset_name || + data.extension_data?.name || + data.asset_id || + '' + : data.asset_name || + data.extension_data?.asset_name || + data.extension_data?.name || + data.description || + data.asset_id || + ''; this.zones = data.zones || []; this.booking_start = Math.floor(data.date / 1000) || From fa4c66421ea46bed837204f99e501c6f956d77e7 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 11 Mar 2026 14:58:06 +1100 Subject: [PATCH 186/392] chore(workplace): tweak display of visitor details --- .../src/app/schedule/schedule-day-view.component.ts | 5 ++++- libs/bookings/src/lib/booking-details-modal.component.ts | 5 ++--- .../src/test/booking-details-modal.component.spec.ts | 6 +++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/workplace/src/app/schedule/schedule-day-view.component.ts b/apps/workplace/src/app/schedule/schedule-day-view.component.ts index b619261a934..5ec3fe05089 100644 --- a/apps/workplace/src/app/schedule/schedule-day-view.component.ts +++ b/apps/workplace/src/app/schedule/schedule-day-view.component.ts @@ -463,7 +463,10 @@ export class ScheduleDayViewComponent extends AsyncHandler implements OnInit { let level_name = ''; if (booking instanceof Booking) { - location = booking.location || booking.asset_name || ''; + location = + booking.booking_type === 'visitor' + ? booking.extension_data?.location || '' + : booking.location || booking.asset_name || ''; const level = this._org.levelWithID(booking.zones); level_name = level?.display_name || level?.name || ''; } else { diff --git a/libs/bookings/src/lib/booking-details-modal.component.ts b/libs/bookings/src/lib/booking-details-modal.component.ts index 50eb4468216..78e86ab58c6 100644 --- a/libs/bookings/src/lib/booking-details-modal.component.ts +++ b/libs/bookings/src/lib/booking-details-modal.component.ts @@ -65,9 +65,9 @@ import { DeskSettingsModalComponent } from './desk-settings-modal.component'; > {{ display_title() }} - @if (is_visitor() && visitor_reason()) { + @if (is_visitor()) {

    - {{ visitor_reason() }} + {{ visitor_display_name() }}

    }
    @@ -543,7 +543,6 @@ export class BookingDetailsModalComponent { public readonly display_title = computed(() => { const booking = this.booking(); if (!booking) return ''; - if (this.is_visitor()) return this._visitorDisplayNameFor(booking); return booking.title || booking.asset_name || booking.asset_id; }); public readonly visitor_display_name = computed(() => 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 037175d93eb..954532e604e 100644 --- a/libs/bookings/src/test/booking-details-modal.component.spec.ts +++ b/libs/bookings/src/test/booking-details-modal.component.spec.ts @@ -78,7 +78,7 @@ describe('BookingDetailsModalComponent', () => { ); }); - it('should prefer attendee name over reason text for visitor title', () => { + it('should show booking title as display title and visitor name separately', () => { (spectator.component as any).booking.set( new Booking({ booking_type: 'visitor', @@ -90,7 +90,7 @@ describe('BookingDetailsModalComponent', () => { } as any), ); - expect(spectator.component.display_title()).toBe('Visitor One'); - expect(spectator.component.visitor_reason()).toBe('Vendor Interview'); + expect(spectator.component.display_title()).toBe('Vendor Interview'); + expect(spectator.component.visitor_display_name()).toBe('Visitor One'); }); }); From b33296977f6e331b7887b64d2446450544b43b2e Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 12 Mar 2026 09:33:07 +1100 Subject: [PATCH 187/392] fix(workplace): save visitor phone to history (PPT-2333) --- .../visitor-flow-invites.component.ts | 11 ++++++--- .../visitor-flow-recent.component.ts | 3 ++- .../visitor-flow.component.ts | 21 +++++++++++++---- .../visitor-flow-recent.component.spec.ts | 23 ++++++++++++++++++- .../tests/book/visitor-flow.component.spec.ts | 22 +++++++++++++++++- 5 files changed, 69 insertions(+), 11 deletions(-) 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 index 18c88911774..7aee9e3633c 100644 --- 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 @@ -304,12 +304,16 @@ export class VisitorFlowInvitesComponent }); // Save to visitor history - const visitor_details = `${asset_id}|${asset_name}|${company}`; + const visitor_details = `${asset_id}|${asset_name}|${company}|${ + item.phone || '' + }`; const old_visitors = this._settings.get('visitor-invitees') || []; const old_visitor_records = this.parseRecentVisitors(old_visitors) .filter((visitor) => visitor.email !== asset_id) .map((visitor) => { - return `${visitor.email}|${visitor.name || ''}|${(visitor as any).company || ''}`; + return `${visitor.email}|${visitor.name || ''}|${ + (visitor as any).company || '' + }|${visitor.phone || ''}`; }); this._settings.saveUserSetting('visitor-invitees', [ ...old_visitor_records, @@ -334,11 +338,12 @@ export class VisitorFlowInvitesComponent for (let index = visitor_history.length - 1; index >= 0; index--) { const item = visitor_history[index]; if (typeof item !== 'string') continue; - const [email, name, company] = item.split('|'); + const [email, name, company, phone] = item.split('|'); const parsed_visitor = { email: this.normalizeEmail(email), name: this.toSafeValue(name), company: this.toSafeValue(company), + phone: this.toSafeValue(phone), }; const email_key = parsed_visitor.email; if (!email_key || unique_visitors.has(email_key)) continue; 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 index f002c009322..58e7d0d837e 100644 --- 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 @@ -192,11 +192,12 @@ export class VisitorFlowRecentComponent implements OnInit { for (let index = visitor_history.length - 1; index >= 0; index--) { const item = visitor_history[index]; if (typeof item !== 'string') continue; - const [email, name, company] = item.split('|'); + const [email, name, company, phone] = item.split('|'); const parsed_visitor = { email: this.normalizeEmail(email), name: this.toSafeValue(name), company: this.toSafeValue(company), + phone: this.toSafeValue(phone), }; const email_key = parsed_visitor.email; if (!email_key || unique_visitors.has(email_key)) continue; 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 index bf44f5db7ce..8bfaf12e6a0 100644 --- 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 @@ -254,8 +254,12 @@ export class VisitorFlowNewComponent extends AsyncHandler implements OnInit { const old_visitors: string[] = this._settings.get('visitor-invitees') || []; const value = this._booking_form.form.getRawValue(); - const toEntry = (email: string, name = '', org = '') => - `${email}|${name}|${org}`; + const toEntry = ( + email: string, + name = '', + org = '', + phone = '', + ) => `${email}|${name}|${org}|${phone}`; if (is_multiple && value.assets?.length) { const emails = new Set( value.assets.map((a) => a.email).filter(Boolean), @@ -266,15 +270,22 @@ export class VisitorFlowNewComponent extends AsyncHandler implements OnInit { ), ...value.assets .filter((a) => !!a.email) - .map((a) => toEntry(a.email, a.name, a.organisation)), + .map((a) => + toEntry( + a.email, + a.name, + a.organisation, + (a as any).phone || '', + ), + ), ]); } else { - const { asset_id, asset_name, company } = value; + const { asset_id, asset_name, company, phone } = value; this._settings.saveUserSetting('visitor-invitees', [ ...old_visitors.filter( (v) => `${v}`.split('|')[0] !== asset_id, ), - toEntry(asset_id, asset_name, company), + toEntry(asset_id, asset_name, company, phone), ]); } } 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 index 984ec5561c6..8c2a7fd8ddd 100644 --- a/apps/workplace/src/tests/book/visitor-flow-recent.component.spec.ts +++ b/apps/workplace/src/tests/book/visitor-flow-recent.component.spec.ts @@ -9,6 +9,7 @@ import { BehaviorSubject } from 'rxjs'; describe('VisitorFlowRecentComponent', () => { let spectator: SpectatorRouting; let form: FormGroup; + let get_setting: jest.Mock; const createComponent = createRoutingFactory({ component: VisitorFlowRecentComponent, @@ -17,7 +18,7 @@ describe('VisitorFlowRecentComponent', () => { MockProvider( SettingsService, { - get: jest.fn(() => []), + get: (...args: any[]) => get_setting(...args), } as any, ), { @@ -26,6 +27,9 @@ describe('VisitorFlowRecentComponent', () => { form = new FormGroup({ id: new FormControl(''), asset_id: new FormControl(''), + asset_name: new FormControl(''), + company: new FormControl(''), + phone: new FormControl(''), assets: new FormControl([]), }); return { @@ -41,6 +45,7 @@ describe('VisitorFlowRecentComponent', () => { }); beforeEach(() => { + get_setting = jest.fn(() => []); spectator = createComponent(); }); @@ -54,4 +59,20 @@ describe('VisitorFlowRecentComponent', () => { 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({ + email: 'visitor.one@example.com', + name: 'Visitor One', + company: 'Acme', + phone: '+61400111222', + }); + + expect(form.value.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 b18a63d0181..2e1ea9925cc 100644 --- a/apps/workplace/src/tests/book/visitor-flow.component.spec.ts +++ b/apps/workplace/src/tests/book/visitor-flow.component.spec.ts @@ -9,6 +9,7 @@ import { VisitorFlowNewComponent } from 'apps/workplace/src/app/book/visitor-flo describe('VisitorFlowNewComponent', () => { let spectator: SpectatorRouting; let form: FormGroup; + let save_user_setting: jest.Mock; const createComponent = createRoutingFactory({ component: VisitorFlowNewComponent, @@ -23,6 +24,8 @@ describe('VisitorFlowNewComponent', () => { description: new FormControl(''), asset_name: new FormControl(''), asset_id: new FormControl(''), + company: new FormControl(''), + phone: new FormControl(''), assets: new FormControl([]), }); return { @@ -40,7 +43,8 @@ describe('VisitorFlowNewComponent', () => { provide: SettingsService, useValue: { get: jest.fn(() => []), - saveUserSetting: jest.fn(), + saveUserSetting: (...args: any[]) => + save_user_setting(...args), }, }, { @@ -57,6 +61,7 @@ describe('VisitorFlowNewComponent', () => { }); beforeEach(() => { + save_user_setting = jest.fn(); spectator = createComponent({ detectChanges: false }); }); @@ -73,4 +78,19 @@ describe('VisitorFlowNewComponent', () => { 'BOOKINGS.EDIT_VISITOR_DETAILS', ); }); + + it('should save visitor phone in recent visitors history', () => { + form.patchValue({ + 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|+61400111222', + ]); + }); }); From 610e51599baa90f1e5bb08144cce8001c6dcf026 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 12 Mar 2026 10:56:55 +1100 Subject: [PATCH 188/392] chore(workplace): wait until loading is finished before applying asset_id to desk form --- .../src/app/book/desk-flow-new/desk-flow.component.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index ea1e4797463..5df964e1a8b 100644 --- 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 @@ -5,13 +5,14 @@ import { ActivatedRoute, Router } from '@angular/router'; import { BookingFormService } from '@placeos/bookings'; import { AsyncHandler, + firstTruthyValueFrom, getInvalidFields, i18n, notifyError, notifySuccess, } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, map } from 'rxjs'; import { DeskFlowAutoAssignComponent } from './desk-flow-auto-assign.component'; import { DeskFlowDetailsComponent } from './desk-flow-details.component'; import { DeskFlowSelectComponent } from './desk-flow-select.component'; @@ -163,6 +164,9 @@ export class DeskFlowNewComponent extends AsyncHandler implements OnInit { ) { return; } + await firstTruthyValueFrom( + this._booking_form.loading.pipe(map((_) => !_)), + ); const resources = await firstValueFrom( this._booking_form.resources, ); From 5367892ec2c0753cb63d1c5d24c8b02655d3dced Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 12 Mar 2026 11:37:36 +1100 Subject: [PATCH 189/392] fix(workplace): fix changing buildings for visitor bookings --- .../visitor-flow-details.component.ts | 65 ++++++----- .../visitor-flow.component.ts | 17 ++- libs/bookings/src/lib/booking-form.service.ts | 109 ++++++++---------- 3 files changed, 97 insertions(+), 94 deletions(-) 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 index 6bc51409f3a..1d0cfa02ba6 100644 --- 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 @@ -1,9 +1,16 @@ import { AsyncPipe } from '@angular/common'; -import { Component, computed, effect, inject, OnInit, signal } from '@angular/core'; +import { + Component, + computed, + effect, + inject, + OnInit, + signal, +} from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatRippleModule } from '@angular/material/core'; 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'; @@ -56,13 +63,17 @@ type VisitorFormType = 'single' | 'group';
    person - {{ 'BOOKINGS.VISITOR_SINGLE' | translate }} + {{ + 'BOOKINGS.VISITOR_SINGLE' | translate + }}
    group - {{ 'BOOKINGS.VISITOR_MULTIPLE' | translate }} + {{ + 'BOOKINGS.VISITOR_MULTIPLE' | translate + }}
    @@ -70,12 +81,12 @@ type VisitorFormType = 'single' | 'group';
    @if (capacity === 3) { - task_alt + task_alt }
    @if (capacity === 5) { - task_alt + task_alt }
@if (capacity === 9) { - task_alt + task_alt }
} + @if (visitorName(bkn)) { +
+ {{ visitorName(bkn) }} +
+ }
{{ bkn.date | date: 'shortTime' }}
@@ -270,6 +284,13 @@ export class ScheduleWeekViewComponent { return 'var(--error)'; } + public visitorName(booking: Booking | CalendarEvent): string { + if (booking instanceof Booking) { + return visitorDisplayNameFor(booking); + } + return ''; + } + public location(booking: Booking | CalendarEvent): string { let location = ''; let level_name = ''; diff --git a/libs/bookings/src/lib/booking-card.component.ts b/libs/bookings/src/lib/booking-card.component.ts index 68ef5b49bf2..25230a55a1d 100644 --- a/libs/bookings/src/lib/booking-card.component.ts +++ b/libs/bookings/src/lib/booking-card.component.ts @@ -31,6 +31,7 @@ import { StatusPillComponent } from 'libs/components/src/lib/status-pill.compone import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { GroupEventDetailsModalComponent } from '../../../events/src/lib/group-event-details-modal.component'; import { BookingDetailsModalComponent } from './booking-details-modal.component'; +import { visitorDisplayNameFor } from './booking.utilities'; import { ParkingService } from './parking.service'; @Component({ @@ -59,7 +60,7 @@ import { ParkingService } from './parking.service'; class="border-base-300 bg-base-100 relative w-full rounded-xl border py-4 shadow-sm" >
@@ -125,21 +126,21 @@ import { ParkingService } from './parking.service'; booking()?.booking_type !== 'group-event' ) {
{{ 'BOOKINGS.ASSOCIATE' | translate }}
} @if (booking()?.booking_type === 'group-event') {
{{ 'BOOKINGS.EVENT' | translate }}
} @if (is_reserved_parking_space | async) {
{{ (booking().status !== 'declined' @@ -329,7 +330,7 @@ export class BookingCardComponent this.raw_description() || booking.asset_name || booking.asset_id ); } - return this._visitorDisplayNameFor(booking); + return visitorDisplayNameFor(booking) || 'Visitor'; } public removeHtmlTags(html: string) { @@ -358,52 +359,4 @@ export class BookingCardComponent this._dialog.open(view_component, { data }); }); } - - private _visitorDisplayNameFor(booking: Booking) { - const asset_id = `${booking?.asset_id || ''}`.trim(); - const group_member_name = this._visitorGroupMemberName(booking); - if (group_member_name) return group_member_name; - const attendee_name = this._visitorAttendeeName(booking); - if (attendee_name) return attendee_name; - const asset_name = `${booking?.extension_data?.visitor_name || booking?.asset_name || ''}`.trim(); - const reason_values = [ - `${booking?.title || ''}`.trim().toLowerCase(), - `${booking?.description || ''}`.trim().toLowerCase(), - ].filter((_) => !!_); - if ( - asset_name && - asset_name.toLowerCase() !== asset_id.toLowerCase() && - !reason_values.includes(asset_name.toLowerCase()) - ) { - return asset_name; - } - return this._formatEmailName(asset_id || asset_name || 'Visitor'); - } - - private _visitorGroupMemberName(booking: Booking) { - const member = (booking.extension_data?.group_members || []).find( - (item) => item?.email === booking.asset_id, - ); - const name = `${member?.name || ''}`.trim(); - return name || ''; - } - - private _visitorAttendeeName(booking: Booking) { - const attendee = - (booking.attendees || []).find((item) => item?.email === booking.asset_id) || - booking.attendees?.[0]; - const name = `${attendee?.name || ''}`.trim(); - return name || ''; - } - - private _formatEmailName(value: string) { - if (!value.includes('@')) return value; - const [local_part] = value.split('@'); - const formatted_local = local_part - .replace(/[._-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - if (!formatted_local) return value; - return formatted_local.replace(/\b\w/g, (char) => char.toUpperCase()); - } } diff --git a/libs/bookings/src/lib/booking-details-modal.component.ts b/libs/bookings/src/lib/booking-details-modal.component.ts index 78e86ab58c6..769fcf3974b 100644 --- a/libs/bookings/src/lib/booking-details-modal.component.ts +++ b/libs/bookings/src/lib/booking-details-modal.component.ts @@ -33,6 +33,7 @@ import { MapPinComponent } from 'libs/components/src/lib/map-pin.component'; import { StatusPillComponent } from 'libs/components/src/lib/status-pill.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { UserPipe } from 'libs/users/src/lib/user.pipe'; +import { visitorDisplayNameFor } from './booking.utilities'; import { checkinBooking, checkinBookingInstance } from './bookings.fn'; import { DeskSettingsModalComponent } from './desk-settings-modal.component'; @@ -163,10 +164,9 @@ import { DeskSettingsModalComponent } from './desk-settings-modal.component';
{{ period() }}
- {{ is_visitor() ? 'person' : 'map' }} + {{ + is_visitor() ? 'person' : 'map' + }}
@if (is_visitor()) {
{{ visitor_display_name() }}
@@ -467,14 +467,17 @@ export class BookingDetailsModalComponent { this.level_or_building()?.display_name || this.level_or_building()?.name || ''; - const resource_name = this.booking().asset_name || this.booking().asset_id; - return location_name ? `${location_name}, ${resource_name}` : resource_name; + const resource_name = + this.booking().asset_name || this.booking().asset_id; + return location_name + ? `${location_name}, ${resource_name}` + : resource_name; }); public readonly building = computed(() => { const zones = this.booking()?.zones || []; const level = this.level(); - const building = this._org.buildings.find((bld) => - zones.includes(bld.id) || bld.id === level?.parent_id, + const building = this._org.buildings.find( + (bld) => zones.includes(bld.id) || bld.id === level?.parent_id, ); if (this._settings.get('app.use_region')) { const region = this._org.regions.find( @@ -545,13 +548,15 @@ export class BookingDetailsModalComponent { if (!booking) return ''; return booking.title || booking.asset_name || booking.asset_id; }); - public readonly visitor_display_name = computed(() => - this._visitorDisplayNameFor(this.booking()), + public readonly visitor_display_name = computed( + () => visitorDisplayNameFor(this.booking()) || 'Visitor', ); public readonly visitor_reason = computed(() => { const booking = this.booking(); if (!booking || !this.is_visitor()) return ''; - const visitor_name = this._visitorDisplayNameFor(booking).toLowerCase(); + const visitor_name = ( + visitorDisplayNameFor(booking) || 'Visitor' + ).toLowerCase(); const reason = `${booking.title || booking.description || ''}`.trim(); if (!reason.length) return ''; return reason.toLowerCase() === visitor_name ? '' : reason; @@ -560,7 +565,7 @@ export class BookingDetailsModalComponent { const booking = this.booking(); const asset_id = `${booking?.asset_id || ''}`.trim(); if (!asset_id || !this._looksLikeEmail(asset_id)) return ''; - const display_name = this._visitorDisplayNameFor(booking); + const display_name = visitorDisplayNameFor(booking) || 'Visitor'; return display_name.toLowerCase() === asset_id.toLowerCase() ? '' : asset_id; @@ -683,55 +688,6 @@ export class BookingDetailsModalComponent { }); } - private _visitorDisplayNameFor(booking: Booking) { - if (!booking) return 'Visitor'; - const asset_id = `${booking.asset_id || ''}`.trim(); - const group_member_name = this._visitorGroupMemberName(booking); - if (group_member_name) return group_member_name; - const attendee_name = this._visitorAttendeeName(booking); - if (attendee_name) return attendee_name; - const asset_name = `${booking.extension_data?.visitor_name || booking.asset_name || ''}`.trim(); - const reason_values = [ - `${booking.title || ''}`.trim().toLowerCase(), - `${booking.description || ''}`.trim().toLowerCase(), - ].filter((_) => !!_); - if ( - asset_name && - asset_name.toLowerCase() !== asset_id.toLowerCase() && - !reason_values.includes(asset_name.toLowerCase()) - ) { - return asset_name; - } - return this._formatEmailName(asset_id || asset_name || 'Visitor'); - } - - private _visitorGroupMemberName(booking: Booking) { - const member = (booking.extension_data?.group_members || []).find( - (item) => item?.email === booking.asset_id, - ); - const name = `${member?.name || ''}`.trim(); - return name || ''; - } - - private _visitorAttendeeName(booking: Booking) { - const attendee = - (booking.attendees || []).find((item) => item?.email === booking.asset_id) || - booking.attendees?.[0]; - const name = `${attendee?.name || ''}`.trim(); - return name || ''; - } - - private _formatEmailName(value: string) { - if (!this._looksLikeEmail(value)) return value; - const [local_part] = value.split('@'); - const formatted_local = local_part - .replace(/[._-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - if (!formatted_local) return value; - return formatted_local.replace(/\b\w/g, (char) => char.toUpperCase()); - } - private _looksLikeEmail(value: string) { return !!value && value.includes('@'); } diff --git a/libs/bookings/src/lib/booking.utilities.ts b/libs/bookings/src/lib/booking.utilities.ts index b2177aab3be..ccddd37adc2 100644 --- a/libs/bookings/src/lib/booking.utilities.ts +++ b/libs/bookings/src/lib/booking.utilities.ts @@ -138,9 +138,7 @@ export function generateBookingForm(booking: Booking = new Booking()) { p2_document_names: new FormControl( booking.extension_data.p2_document_names || [], ), - attachments: new FormControl( - booking.extension_data.attachments || [], - ), + attachments: new FormControl(booking.extension_data.attachments || []), update_master: new FormControl(false), self_registered: new FormControl(false), is_assgined: new FormControl(false), @@ -372,3 +370,44 @@ export function loadLockers( shareReplay(1), ); } + +/** Resolve the display name for a visitor booking. Returns empty string if not a visitor booking or no name can be resolved. */ +export function visitorDisplayNameFor(booking: Booking): string { + if (!booking || booking.booking_type !== 'visitor') return ''; + const asset_id = `${booking.asset_id || ''}`.trim(); + const group_member = (booking.extension_data?.group_members || []).find( + (item) => item?.email === booking.asset_id, + ); + if (group_member?.name?.trim()) return group_member.name.trim(); + const attendee = + (booking.attendees || []).find( + (item) => item?.email === booking.asset_id, + ) || booking.attendees?.[0]; + if (attendee?.name?.trim()) return attendee.name.trim(); + const asset_name = + `${booking.extension_data?.visitor_name || booking.asset_name || ''}`.trim(); + const reason_values = [ + `${booking.title || ''}`.trim().toLowerCase(), + `${booking.description || ''}`.trim().toLowerCase(), + ].filter((_) => !!_); + if ( + asset_name && + asset_name.toLowerCase() !== asset_id.toLowerCase() && + !reason_values.includes(asset_name.toLowerCase()) + ) { + return asset_name; + } + return formatEmailAsName(asset_id || asset_name || ''); +} + +/** Format an email address as a human-readable name. */ +export function formatEmailAsName(value: string): string { + if (!value || !value.includes('@')) return value || ''; + const [local_part] = value.split('@'); + const formatted_local = local_part + .replace(/[._-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (!formatted_local) return value; + return formatted_local.replace(/\b\w/g, (char) => char.toUpperCase()); +} From 10fa7bdfb9be174c2f37d11817f2518f799aaa05 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 23 Mar 2026 15:24:22 +1100 Subject: [PATCH 198/392] chore(workplace): add bookable hours handling for new visitor flow --- .../visitor-flow-details.component.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 index 1d0cfa02ba6..083c367123c 100644 --- 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 @@ -161,7 +161,9 @@ type VisitorFormType = 'single' | 'group'; " [ngModelOptions]="{ standalone: true }" [disabled]="is_edit_in_progress()" + [range]="effective_bookable_hours()" [use_24hr]="use_24hr()" + [timezone]="timezone" />
@@ -173,7 +175,9 @@ type VisitorFormType = 'single' | 'group'; formControlName="duration" [time]="form_value().date" [max]="max_duration()" + [end_time]="effective_bookable_hours()?.end" [use_24hr]="use_24hr()" + [timezone]="timezone" />
@@ -281,6 +285,18 @@ export class VisitorFlowDetailsComponent implements OnInit { ), ); + 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, From fc1f3ef4ce2f4231b4e152b2ca477c95a2ebb09d Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 27 Mar 2026 01:19:06 +1100 Subject: [PATCH 199/392] feat(workplace): update desk auto-assign to factor in user groups --- .../auto-assigned-desk-modal.component.ts | 35 +++++++++++++------ libs/bookings/src/lib/booking-form.service.ts | 16 +++++++-- libs/common/src/lib/types/desk.class.ts | 3 ++ 3 files changed, 42 insertions(+), 12 deletions(-) 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 index b4a17536607..e86018ff1a3 100644 --- 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 @@ -19,6 +19,7 @@ import { } from '@placeos/bookings'; import { AsyncHandler, + currentUser, Desk, firstTruthyValueFrom, i18n, @@ -44,7 +45,7 @@ import { isBefore, startOfMinute } from 'date-fns'; >

{{ 'BOOKINGS.DESK_AUTO_ASSIGNED_TITLE' | translate }} @@ -84,7 +85,7 @@ import { isBefore, startOfMinute } from 'date-fns';
done
@@ -103,12 +104,12 @@ import { isBefore, startOfMinute } from 'date-fns';
chair
-
+
{{ 'RESOURCE.DESK' | translate }}
@@ -122,7 +123,7 @@ import { isBefore, startOfMinute } from 'date-fns';
layers
-
+
{{ 'COMMON.FLOOR' | translate }}
@@ -133,7 +134,7 @@ import { isBefore, startOfMinute } from 'date-fns';
place
-
+
{{ 'BOOKINGS.DESK_NEIGHBOURHOOD' | translate @@ -151,7 +152,7 @@ import { isBefore, startOfMinute } from 'date-fns'; {{ 'BOOKINGS.DESK_LOCATION_ON_MAP' | translate }}
@if (map_url()) { } @else {
map @@ -181,7 +182,7 @@ import { isBefore, startOfMinute } from 'date-fns';
+
+ + + + {{ + 'SIGNAGE_MANAGER.ORIENTATION_UNSPECIFIED' + | translate + }} + {{ + 'SIGNAGE_MANAGER.ORIENTATION_LANDSCAPE' + | translate + }} + {{ + 'SIGNAGE_MANAGER.ORIENTATION_PORTRAIT' + | translate + }} + {{ + 'SIGNAGE_MANAGER.ORIENTATION_SQUARE' | translate + }} + + +
+
+

+ {{ 'SIGNAGE_MANAGER.DISPLAY_ZONES' | translate }} +

+

+ {{ 'SIGNAGE_MANAGER.DISPLAY_ZONES_HINT' | translate }} +

+ @if (selected_zones().length) { +
+ @for (zone of selected_zones(); track zone.id) { +
+ {{ + zone.display_name || + zone.name || + zone.id + }} + +
+ } +
+ } +
+ +
+
+ + + `, + imports: [ + FormField, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + FullscreenModalShellComponent, + IconComponent, + TranslatePipe, + ZoneSelectTreeComponent, + ], +}) +export class DisplayEditModalComponent { + private readonly _data = inject(MAT_DIALOG_DATA); + private readonly _dialog_ref = inject( + MatDialogRef, + ); + private readonly _selected_zone_items = signal([]); + + public readonly display = this._data.display; + public readonly loading = signal(false); + public readonly roots = this._data.roots; + public readonly selected_zone = linkedSignal( + { + source: this.roots, + computation: (roots, previous) => + previous?.value || roots[0] || null, + }, + ); + public readonly zone_list = new PagedSearch((search) => { + const parent_id = this.selected_zone()?.id; + return parent_id && search.trim() + ? this._data.query_zones(search, parent_id) + : null; + }, byDisplayName); + public readonly loadChildren = this._data.load_children; + public readonly model = signal({ + name: this.display.display_name || '', + description: this.display.description || '', + orientation: this.display.orientation || 'unspecified', + zones: this.display.id + ? [...this.display.zones] + : [...this._data.default_zone_ids], + }); + public readonly form = form(this.model, (path) => required(path.name)); + public readonly selected_zones = computed(() => { + const selected_zone_ids = this.model().zones; + const zones = new Map( + [...this._data.zones(), ...this._selected_zone_items()].map( + (zone) => [zone.id, zone], + ), + ); + return selected_zone_ids.map( + (zone_id) => zones.get(zone_id) || new PlaceZone({ id: zone_id }), + ); + }); + + public addZone(zone: PlaceZone) { + this._selected_zone_items.update((zones) => [ + ...zones.filter((item) => item.id !== zone.id), + zone, + ]); + this.model.update((model) => ({ + ...model, + zones: model.zones.includes(zone.id) + ? model.zones + : [...model.zones, zone.id], + })); + } + + public removeZone(zone_id: string) { + this.model.update((model) => ({ + ...model, + zones: model.zones.filter((id) => id !== zone_id), + })); + } + + public async saveDisplay() { + await submit(this.form, async () => { + this.loading.set(true); + this._dialog_ref.disableClose = true; + const form_value = this.model(); + const data: Partial = { + name: `SIGNAGE ${form_value.name}`, + display_name: form_value.name, + description: form_value.description, + orientation: form_value.orientation, + signage: true, + zones: form_value.zones, + }; + try { + const result = this.display.id + ? await this._data.onEdit(this.display.id, data) + : await this._data.onAdd(data); + this._dialog_ref.disableClose = false; + this._dialog_ref.close(result); + } finally { + this.loading.set(false); + this._dialog_ref.disableClose = false; + } + }); + } +} diff --git a/apps/signage-manager/src/app/displays/display-header.component.ts b/apps/signage-manager/src/app/displays/display-header.component.ts index c23f19c09b0..71351827da0 100644 --- a/apps/signage-manager/src/app/displays/display-header.component.ts +++ b/apps/signage-manager/src/app/displays/display-header.component.ts @@ -1,5 +1,7 @@ import { Component, computed, inject } from '@angular/core'; -import { TranslatePipe } from '@placeos/components'; +import { MatRippleModule } from '@angular/material/core'; +import { Router } from '@angular/router'; +import { IconComponent, TranslatePipe } from '@placeos/components'; import { GroupBreadcrumbsComponent } from '../shared/group-breadcrumbs.component'; import { SignageService } from '../signage.service'; @@ -26,14 +28,41 @@ import { SignageService } from '../signage.service';
+ @if (can_create()) { + + }
`, - imports: [TranslatePipe, GroupBreadcrumbsComponent], + imports: [ + MatRippleModule, + IconComponent, + TranslatePipe, + GroupBreadcrumbsComponent, + ], }) export class DisplayHeaderComponent { private readonly _service = inject(SignageService); + private readonly _router = inject(Router); public readonly total_count = computed( () => this._service.filtered_displays().length, ); + public readonly can_create = this._service.can_create; + + public async addDisplay() { + const display = await this._service.addDisplay(); + if (display?.id) await this._router.navigate(['/displays', display.id]); + } } diff --git a/apps/signage-manager/src/app/displays/display-zones.util.ts b/apps/signage-manager/src/app/displays/display-zones.util.ts new file mode 100644 index 00000000000..dda78fe8812 --- /dev/null +++ b/apps/signage-manager/src/app/displays/display-zones.util.ts @@ -0,0 +1,40 @@ +import { PlaceZone } from '@placeos/ts-client'; + +/** Return each selected zone with its ancestors, ordered parent first. */ +export async function displayZoneIds( + selected_zones: PlaceZone[], + known_zones: PlaceZone[], + load_zone: (zone_id: string) => Promise, +) { + const zones = new Map(known_zones.map((zone) => [zone.id, zone])); + for (const zone of selected_zones) zones.set(zone.id, zone); + + const zone_ids: string[] = []; + const added = new Set(); + for (const selected_zone of selected_zones) { + const path: string[] = []; + const visited = new Set(); + let zone: PlaceZone | null = selected_zone; + while (zone?.id && !visited.has(zone.id)) { + path.unshift(zone.id); + visited.add(zone.id); + if (!zone.parent_id) break; + let parent = zones.get(zone.parent_id) || null; + if (!parent) { + parent = await load_zone(zone.parent_id).catch(() => null); + if (parent) zones.set(parent.id, parent); + } + if (!parent) { + path.unshift(zone.parent_id); + break; + } + zone = parent; + } + for (const zone_id of path) { + if (added.has(zone_id)) continue; + added.add(zone_id); + zone_ids.push(zone_id); + } + } + return zone_ids; +} diff --git a/apps/signage-manager/src/app/displays/displays.component.ts b/apps/signage-manager/src/app/displays/displays.component.ts index 7883c6cee9b..a672e32f39c 100644 --- a/apps/signage-manager/src/app/displays/displays.component.ts +++ b/apps/signage-manager/src/app/displays/displays.component.ts @@ -79,7 +79,51 @@ function parseDisplayTab( {{ selected_display().description }}
} +
+ {{ selected_display().orientation }} +
+ @if (can_update()) { + + } + @if (can_delete_displays()) { + + } this._hasGroupPermission(SignageGroupPermission.Delete), ); + public readonly can_delete_displays = this.is_sys_admin; public readonly can_approve = computed(() => this._hasGroupPermission(SignageGroupPermission.Approve), ); @@ -2263,6 +2269,36 @@ export class SignageService { })); } + private _addDisplayToList(display: PlaceSystem) { + if (!display?.id) return; + const item = decodeEntityNames(display); + this._display_items.update((items) => [ + item, + ...items.filter((existing) => existing.id !== item.id), + ]); + this._display_cache.update((cache) => ({ + ...cache, + [item.id]: item, + })); + this._cacheDisplay(item); + } + + private _removeDisplayFromList(display_id: string) { + this._display_items.update((items) => + items.filter((item) => item.id !== display_id), + ); + this._display_cache.update((cache) => { + const next = { ...cache }; + delete next[display_id]; + return next; + }); + this._display_overrides.update((overrides) => { + const next = { ...overrides }; + delete next[display_id]; + return next; + }); + } + private _cacheZone(zone: any) { if (!zone?.id) return; this._zone_overrides.update((state) => ({ @@ -3130,6 +3166,139 @@ export class SignageService { notifySuccess(i18n('SIGNAGE_MANAGER.SVC_PLAYLIST_REMOVED_ZONE')); } + public async addDisplay() { + if ( + !this._requirePermission( + this.can_create(), + i18n('SIGNAGE_MANAGER.SVC_NO_CREATE_DISPLAYS'), + ) + ) + return null; + const default_zone_ids = await this._defaultDisplayZoneIds(); + const ref = this._dialog.open(DisplayEditModalComponent, { + data: { + display: new PlaceSystem({}), + default_zone_ids, + roots: this.root_zones, + zones: this.all_zones, + load_children: (parent_id: string) => + this.zoneChildren(parent_id), + query_zones: (search: string, parent_id: string) => + this.querySelectableZones(search, parent_id), + onAdd: (data: Partial) => addSystem(data), + onEdit: (id: string, data: Partial) => + updateSystem(id, data), + }, + panelClass: 'mobile-fullscreen', + }); + const result = (await dialogClosed(ref)) as PlaceSystem | null; + if (!result) return null; + this._addDisplayToList(result); + this.selected_display.set(result); + notifySuccess(i18n('SIGNAGE_MANAGER.SVC_DISPLAY_SAVED')); + return result; + } + + public async editDisplay(display: PlaceSystem) { + if ( + !this._requirePermission( + this.can_update(), + i18n('SIGNAGE_MANAGER.SVC_NO_UPDATE_DISPLAYS'), + ) + ) + return null; + const ref = this._dialog.open(DisplayEditModalComponent, { + data: { + display, + default_zone_ids: [], + roots: this.root_zones, + zones: this.all_zones, + load_children: (parent_id: string) => + this.zoneChildren(parent_id), + query_zones: (search: string, parent_id: string) => + this.querySelectableZones(search, parent_id), + onAdd: (data: Partial) => addSystem(data), + onEdit: (id: string, data: Partial) => + updateSystem(id, data), + }, + panelClass: 'mobile-fullscreen', + }); + const result = (await dialogClosed(ref)) as PlaceSystem | null; + if (!result) return null; + this._addDisplayToList(result); + this.selected_display.set(result); + notifySuccess(i18n('SIGNAGE_MANAGER.SVC_DISPLAY_SAVED')); + return result; + } + + public async removeDisplay(display: PlaceSystem) { + if (!display?.id) return false; + if ( + !this._requirePermission( + this.can_delete_displays(), + i18n('SIGNAGE_MANAGER.SVC_NO_DELETE_DISPLAYS'), + ) + ) + return false; + const result = await openConfirmModal( + { + title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_DISPLAY_TITLE'), + content: i18n('SIGNAGE_MANAGER.SVC_DELETE_NAMED', { + name: display.display_name || display.name, + }), + icon: { content: 'delete' }, + }, + this._dialog, + ); + if (result.reason !== 'done') return false; + const used_elsewhere = !!( + display.map_id || + display.email || + display.modules.length || + display.module_list.length + ); + if (used_elsewhere) { + await updateSystem(display.id, { signage: false }); + } else { + await removeSystem(display.id); + } + result.close(); + this._removeDisplayFromList(display.id); + if (this.selected_display()?.id === display.id) { + this.selected_display.set(null); + } + notifySuccess(i18n('SIGNAGE_MANAGER.SVC_DISPLAY_REMOVED')); + return true; + } + + private async _defaultDisplayZoneIds() { + const group_id = this._api_group_id(); + const active_zone = + this._org.building || this._org.region || this._org.organisation; + let roots = group_id + ? this.root_zones() + : active_zone + ? [active_zone] + : []; + if (group_id && !roots.length) { + const result = await queryZones({ + group_id, + limit: 500, + include_children_count: true, + } as any).catch(() => null); + roots = (result?.data || []).map(decodeEntityNames); + } + const known_zones = [ + ...this.all_zones(), + this._org.organisation, + this._org.region, + this._org.building, + ].filter((zone): zone is PlaceZone => !!zone?.id); + return displayZoneIds(roots, known_zones, async (zone_id) => + showZone(zone_id).catch(() => null), + ); + } + public async addDisplayToZone(zone: any) { if ( !this._requirePermission( diff --git a/apps/signage-manager/src/tests/displays/display-edit-modal.component.spec.ts b/apps/signage-manager/src/tests/displays/display-edit-modal.component.spec.ts new file mode 100644 index 00000000000..fb20c5af658 --- /dev/null +++ b/apps/signage-manager/src/tests/displays/display-edit-modal.component.spec.ts @@ -0,0 +1,126 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { PlaceSystem, PlaceZone } from '@placeos/ts-client'; +import { + DisplayEditModalComponent, + DisplayEditModalData, +} from '../../app/displays/display-edit-modal.component'; + +describe('DisplayEditModalComponent', () => { + const roots = signal([]); + const zones = signal([]); + const on_add = vi.fn(); + const on_edit = vi.fn(); + const dialog_ref = { close: vi.fn(), disableClose: false }; + + async function make( + display = new PlaceSystem({}), + default_zone_ids: string[] = [], + ) { + const data: DisplayEditModalData = { + display, + default_zone_ids, + roots, + zones, + load_children: vi.fn().mockResolvedValue([]), + query_zones: vi.fn().mockReturnValue(null), + onAdd: on_add, + onEdit: on_edit, + }; + await TestBed.configureTestingModule({ + imports: [DisplayEditModalComponent], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: data }, + { provide: MatDialogRef, useValue: dialog_ref }, + ], + }) + .overrideComponent(DisplayEditModalComponent, { + set: { template: '', imports: [] }, + }) + .compileComponents(); + return TestBed.createComponent(DisplayEditModalComponent) + .componentInstance; + } + + beforeEach(() => { + vi.clearAllMocks(); + dialog_ref.disableClose = false; + roots.set([new PlaceZone({ id: 'org', name: 'Organisation' })]); + zones.set([ + new PlaceZone({ id: 'org', name: 'Organisation' }), + new PlaceZone({ id: 'building', name: 'Building' }), + ]); + on_add.mockResolvedValue(new PlaceSystem({ id: 'display-new' })); + on_edit.mockResolvedValue(new PlaceSystem({ id: 'display-1' })); + }); + + it('creates a display with its default zones and orientation', async () => { + const component = await make(new PlaceSystem({}), ['org', 'building']); + component.model.update((model) => ({ + ...model, + name: 'Foyer', + description: 'Main entrance', + orientation: 'portrait', + })); + + await component.saveDisplay(); + + expect(on_add).toHaveBeenCalledWith({ + name: 'SIGNAGE Foyer', + display_name: 'Foyer', + description: 'Main entrance', + orientation: 'portrait', + signage: true, + zones: ['org', 'building'], + }); + expect(on_edit).not.toHaveBeenCalled(); + expect(dialog_ref.close).toHaveBeenCalledWith( + expect.objectContaining({ id: 'display-new' }), + ); + }); + + it('updates an existing display and preserves its zones', async () => { + const component = await make( + new PlaceSystem({ + id: 'display-1', + display_name: 'Old name', + zones: ['existing-zone'], + }), + ['unused-default'], + ); + component.model.update((model) => ({ ...model, name: 'Cafe' })); + + await component.saveDisplay(); + + expect(on_edit).toHaveBeenCalledWith( + 'display-1', + expect.objectContaining({ + display_name: 'Cafe', + zones: ['existing-zone'], + }), + ); + expect(on_add).not.toHaveBeenCalled(); + }); + + it('adds and removes zones without duplicates', async () => { + const component = await make(new PlaceSystem({}), ['org']); + const building = new PlaceZone({ id: 'building', name: 'Building' }); + + component.addZone(building); + component.addZone(building); + component.removeZone('org'); + + expect(component.model().zones).toEqual(['building']); + expect(component.selected_zones()[0].name).toBe('Building'); + }); + + it('does not save without a name', async () => { + const component = await make(); + + await component.saveDisplay(); + + expect(on_add).not.toHaveBeenCalled(); + expect(on_edit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/signage-manager/src/tests/displays/display-header.component.spec.ts b/apps/signage-manager/src/tests/displays/display-header.component.spec.ts index a8484718879..3fd0464db69 100644 --- a/apps/signage-manager/src/tests/displays/display-header.component.spec.ts +++ b/apps/signage-manager/src/tests/displays/display-header.component.spec.ts @@ -1,21 +1,36 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; import { DisplayHeaderComponent } from '../../app/displays/display-header.component'; import { SignageService } from '../../app/signage.service'; describe('DisplayHeaderComponent', () => { const filtered_displays = signal([]); - const service_stub = { filtered_displays }; + const can_create = signal(false); + const add_display = vi.fn(); + const navigate = vi.fn(); + const service_stub = { + filtered_displays, + can_create, + addDisplay: add_display, + }; function make() { TestBed.configureTestingModule({ - providers: [{ provide: SignageService, useValue: service_stub }], + providers: [ + { provide: SignageService, useValue: service_stub }, + { provide: Router, useValue: { navigate } }, + ], }); - return TestBed.createComponent(DisplayHeaderComponent).componentInstance; + return TestBed.createComponent(DisplayHeaderComponent) + .componentInstance; } beforeEach(() => { + vi.clearAllMocks(); filtered_displays.set([]); + can_create.set(false); + add_display.mockResolvedValue(null); }); it('reports the number of filtered displays', () => { @@ -25,4 +40,22 @@ describe('DisplayHeaderComponent', () => { filtered_displays.set([{ id: 'a' }, { id: 'b' }, { id: 'c' }]); expect(component.total_count()).toBe(3); }); + + it('creates displays only when the group allows it', () => { + const component = make(); + expect(component.can_create()).toBe(false); + + can_create.set(true); + expect(component.can_create()).toBe(true); + }); + + it('opens the created display', async () => { + add_display.mockResolvedValue({ id: 'display-1' }); + const component = make(); + + await component.addDisplay(); + + expect(add_display).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(['/displays', 'display-1']); + }); }); diff --git a/apps/signage-manager/src/tests/displays/display-zones.util.spec.ts b/apps/signage-manager/src/tests/displays/display-zones.util.spec.ts new file mode 100644 index 00000000000..9d5e9a87892 --- /dev/null +++ b/apps/signage-manager/src/tests/displays/display-zones.util.spec.ts @@ -0,0 +1,39 @@ +import { PlaceZone } from '@placeos/ts-client'; +import { displayZoneIds } from '../../app/displays/display-zones.util'; + +describe('displayZoneIds', () => { + it('includes the selected group zone and its parent tree', async () => { + const organisation = new PlaceZone({ id: 'org' }); + const region = new PlaceZone({ id: 'region', parent_id: 'org' }); + const building = new PlaceZone({ + id: 'building', + parent_id: 'region', + }); + const load_zone = vi.fn(async (zone_id: string) => + zone_id === region.id ? region : null, + ); + + const result = await displayZoneIds( + [building], + [organisation], + load_zone, + ); + + expect(result).toEqual(['org', 'region', 'building']); + expect(load_zone).toHaveBeenCalledWith('region'); + }); + + it('deduplicates shared ancestors for multiple group zones', async () => { + const organisation = new PlaceZone({ id: 'org' }); + const first = new PlaceZone({ id: 'first', parent_id: 'org' }); + const second = new PlaceZone({ id: 'second', parent_id: 'org' }); + + const result = await displayZoneIds( + [first, second], + [organisation], + async () => null, + ); + + expect(result).toEqual(['org', 'first', 'second']); + }); +}); diff --git a/apps/signage-manager/src/tests/displays/displays.component.spec.ts b/apps/signage-manager/src/tests/displays/displays.component.spec.ts index a631b6226a9..dd4f0291f49 100644 --- a/apps/signage-manager/src/tests/displays/displays.component.spec.ts +++ b/apps/signage-manager/src/tests/displays/displays.component.spec.ts @@ -9,8 +9,21 @@ describe('DisplaysSectionComponent', () => { const displays = signal([]); const playlists = signal([]); const zones = signal([]); + const can_update = signal(false); + const can_delete_displays = signal(false); const navigate = vi.fn(); - const service_stub = { selected_display, displays, playlists, zones }; + const edit_display = vi.fn(); + const remove_display = vi.fn(); + const service_stub = { + selected_display, + displays, + playlists, + zones, + can_update, + can_delete_displays, + editDisplay: edit_display, + removeDisplay: remove_display, + }; const router_stub = { navigate }; async function make(): Promise< @@ -38,6 +51,9 @@ describe('DisplaysSectionComponent', () => { displays.set([]); playlists.set([]); zones.set([]); + can_update.set(false); + can_delete_displays.set(false); + remove_display.mockResolvedValue(false); }); it('counts the playlists and zones attached to the selected display', async () => { @@ -128,4 +144,26 @@ describe('DisplaysSectionComponent', () => { expect(selected_display()).toBeNull(); expect(navigate).toHaveBeenCalledWith(['/displays'], {}); }); + + it('edits the selected display', async () => { + const display = { id: 'd1' }; + selected_display.set(display); + const [component] = await make(); + + component.editDisplay(); + + expect(edit_display).toHaveBeenCalledWith(display); + }); + + it('returns to the list after deleting the selected display', async () => { + const display = { id: 'd1' }; + selected_display.set(display); + remove_display.mockResolvedValue(true); + const [component] = await make(); + + await component.removeDisplay(); + + expect(remove_display).toHaveBeenCalledWith(display); + expect(navigate).toHaveBeenCalledWith(['/displays'], {}); + }); }); diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 9f9876755b0..198a50aa9fc 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -70,6 +70,7 @@ "CLOSE_REQUEST_APPROVAL": "Close request approval dialog", "COLLAPSE_GROUP": "Collapse group {{ name }}", "COLLAPSE_ZONE": "Collapse zone {{ name }}", + "CREATE_NEW_DISPLAY": "Create new display", "CREATE_NEW_PLAYLIST": "Create new playlist", "CREATE_NEW_TEMPLATE": "Create new template", "CUSTOM_SCHEDULE": "Custom schedule", @@ -79,7 +80,9 @@ "DEFAULT_DURATION": "Default Duration", "DEFAULT_PERMISSIONS": "No permissions", "DEFAULT_PLAY_TIME": "Default Play Time", + "DELETE_DISPLAY_TOOLTIP": "Delete display", "DELETE_PLAYLIST_TOOLTIP": "Delete playlist", + "DELETE_SELECTED_DISPLAY": "Delete selected display", "DELETE_SELECTED_PLAYLIST": "Delete selected playlist", "DELETE_SELECTED_TEMPLATE": "Delete selected template", "DELETE_TEMPLATE_TOOLTIP": "Delete template", @@ -90,8 +93,14 @@ "DISPLAY_COUNT_LABEL": "{{ count }} displays", "DISPLAY_COUNT_LABEL_1": "{{ count }} display", "DISPLAY_DETAILS_TABS": "Display details tabs", + "DISPLAY_EDIT": "Edit Display", + "DISPLAY_NAME_ARIA": "Display name", "DISPLAY_NO_ZONES": "This display is not in any zones.", + "DISPLAY_ORIENTATION_ARIA": "Display orientation", + "DISPLAY_SAVING": "Saving display...", "DISPLAY_SELECT_DETAILS": "Select a display to view its details.", + "DISPLAY_ZONES": "Display zones", + "DISPLAY_ZONES_HINT": "Select a zone to add it to this display.", "DRAG_MEDIA_HINT": "Drag media onto a playlist to add it", "DURATION_DAY": "{{ count }} days", "DURATION_DAY_1": "{{ count }} day", @@ -99,8 +108,10 @@ "DURATION_HOUR_1": "{{ count }} hour", "DURATION_MINUTE": "{{ count }} minutes", "DURATION_MINUTE_1": "{{ count }} minute", + "EDIT_DISPLAY_TOOLTIP": "Edit display", "EDIT_GROUP_TOOLTIP": "Edit group", "EDIT_PLAYLIST_TOOLTIP": "Edit playlist", + "EDIT_SELECTED_DISPLAY": "Edit selected display", "EDIT_SELECTED_PLAYLIST": "Edit selected playlist", "EDIT_SELECTED_TEMPLATE": "Edit selected template", "EDIT_TEMPLATE_TOOLTIP": "Edit template", @@ -159,6 +170,7 @@ "NAV_SCHEDULES": "Schedules", "NAV_TEMPLATES": "Templates", "NAV_ZONES": "Zones", + "NEW_DISPLAY": "New Display", "NEW_PLAYLIST": "New Playlist", "NEW_TEMPLATE": "New Template", "NEXT_5_PLAYS": "Next 5 Plays", @@ -202,6 +214,7 @@ "ORIENTATION_LANDSCAPE": "Landscape", "ORIENTATION_PORTRAIT": "Portrait", "ORIENTATION_SQUARE": "Square", + "ORIENTATION_UNSPECIFIED": "Unspecified", "PARENT_REQUIRED": "Parent group is required", "PERMISSIONS": "Permissions", "PERM_CREATE": "Create", @@ -255,6 +268,7 @@ "RECURRING_SCHEDULE": "Recurring schedule", "REMOVE_DISPLAY": "Remove display", "REMOVE_DISPLAY_FROM_PLAYLIST": "Remove display {{ name }} from playlist", + "REMOVE_DISPLAY_ZONE": "Remove zone {{ name }} from display", "REMOVE_FROM_PLAYLIST": "Remove from Playlist", "REMOVE_GROUP_TOOLTIP": "Remove group", "REMOVE_PLAYLIST_FROM_DISPLAY": "Remove playlist {{ name }} from display", @@ -336,8 +350,10 @@ "SVC_DISPLAY_ADDED_PLAYLIST": "Display added to playlist", "SVC_DISPLAY_ADDED_ZONE": "Display added to zone", "SVC_DISPLAY_IN_ZONE": "Display already assigned to this zone.", + "SVC_DISPLAY_REMOVED": "Display removed", "SVC_DISPLAY_REMOVED_PLAYLIST": "Display removed from playlist", "SVC_DISPLAY_REMOVED_ZONE": "Display removed from zone", + "SVC_DISPLAY_SAVED": "Display saved", "SVC_ERR_ADD_USER": "Error adding group user", "SVC_ERR_ADD_ZONE": "Error adding group zone", "SVC_ERR_CONVERT_IMAGE": "Unable to convert image", @@ -358,9 +374,11 @@ "SVC_MEDIA_UPLOAD_FAILED": "Media upload failed. Please try again.", "SVC_NO_APPROVERS": "Unable to load playlist approvers.", "SVC_NO_APPROVE_PLAYLISTS": "You cannot approve playlists in this group.", + "SVC_NO_CREATE_DISPLAYS": "You cannot create displays in this group.", "SVC_NO_CREATE_MEDIA": "You cannot create media in this group.", "SVC_NO_CREATE_PLAYLISTS": "You cannot create playlists in this group.", "SVC_NO_CREATE_TEMPLATES": "You cannot create templates in this group.", + "SVC_NO_DELETE_DISPLAYS": "Only system administrators can delete displays.", "SVC_NO_DELETE_MEDIA": "You cannot delete media in this group.", "SVC_NO_DELETE_PLAYLISTS": "You cannot delete playlists in this group.", "SVC_NO_DELETE_TEMPLATES": "You cannot delete templates in this group.", @@ -369,6 +387,7 @@ "SVC_NO_MANAGE_GROUP": "You cannot manage this signage group.", "SVC_NO_SHARE_ITEMS": "You cannot share items from this group.", "SVC_NO_UPDATE_ASSIGNMENTS": "You cannot update signage assignments in this group.", + "SVC_NO_UPDATE_DISPLAYS": "You cannot update displays in this group.", "SVC_NO_UPDATE_MEDIA": "You cannot update media in this group.", "SVC_NO_UPDATE_PLAYLISTS": "You cannot update playlists in this group.", "SVC_NO_UPDATE_TEMPLATES": "You cannot update templates in this group.", @@ -383,6 +402,7 @@ "SVC_PLAYLIST_SHARED": "Playlist shared", "SVC_PLAYLIST_UPDATED": "Playlist updated", "SVC_REMOVE_GROUP_TITLE": "Remove signage group?", + "SVC_REMOVE_DISPLAY_TITLE": "Remove display?", "SVC_REMOVE_MEDIA_TITLE": "Remove media?", "SVC_REMOVE_NAMED_FROM_GROUP": "Remove \"{{ name }}\" from this group?", "SVC_REMOVE_PLAYLIST_TITLE": "Remove playlist?", diff --git a/shared/assets/locale/en-US.json b/shared/assets/locale/en-US.json index 1f07f58f8e9..fc039dd0531 100644 --- a/shared/assets/locale/en-US.json +++ b/shared/assets/locale/en-US.json @@ -2105,6 +2105,7 @@ "CLOSE_REQUEST_APPROVAL": "Close request approval dialog", "COLLAPSE_GROUP": "Collapse group {{ name }}", "COLLAPSE_ZONE": "Collapse zone {{ name }}", + "CREATE_NEW_DISPLAY": "Create new display", "CREATE_NEW_PLAYLIST": "Create new playlist", "CREATE_NEW_TEMPLATE": "Create new template", "CUSTOM_SCHEDULE": "Custom schedule", @@ -2114,7 +2115,9 @@ "DEFAULT_DURATION": "Default Duration", "DEFAULT_PERMISSIONS": "No permissions", "DEFAULT_PLAY_TIME": "Default Play Time", + "DELETE_DISPLAY_TOOLTIP": "Delete display", "DELETE_PLAYLIST_TOOLTIP": "Delete playlist", + "DELETE_SELECTED_DISPLAY": "Delete selected display", "DELETE_SELECTED_PLAYLIST": "Delete selected playlist", "DELETE_SELECTED_TEMPLATE": "Delete selected template", "DELETE_TEMPLATE_TOOLTIP": "Delete template", @@ -2125,8 +2128,14 @@ "DISPLAY_COUNT_LABEL": "{{ count }} displays", "DISPLAY_COUNT_LABEL_1": "{{ count }} display", "DISPLAY_DETAILS_TABS": "Display details tabs", + "DISPLAY_EDIT": "Edit Display", + "DISPLAY_NAME_ARIA": "Display name", "DISPLAY_NO_ZONES": "This display is not in any zones.", + "DISPLAY_ORIENTATION_ARIA": "Display orientation", + "DISPLAY_SAVING": "Saving display...", "DISPLAY_SELECT_DETAILS": "Select a display to view its details.", + "DISPLAY_ZONES": "Display zones", + "DISPLAY_ZONES_HINT": "Select a zone to add it to this display.", "DRAG_MEDIA_HINT": "Drag media onto a playlist to add it", "DURATION_DAY": "{{ count }} days", "DURATION_DAY_1": "{{ count }} day", @@ -2134,8 +2143,10 @@ "DURATION_HOUR_1": "{{ count }} hour", "DURATION_MINUTE": "{{ count }} minutes", "DURATION_MINUTE_1": "{{ count }} minute", + "EDIT_DISPLAY_TOOLTIP": "Edit display", "EDIT_GROUP_TOOLTIP": "Edit group", "EDIT_PLAYLIST_TOOLTIP": "Edit playlist", + "EDIT_SELECTED_DISPLAY": "Edit selected display", "EDIT_SELECTED_PLAYLIST": "Edit selected playlist", "EDIT_SELECTED_TEMPLATE": "Edit selected template", "EDIT_TEMPLATE_TOOLTIP": "Edit template", @@ -2194,6 +2205,7 @@ "NAV_SCHEDULES": "Schedules", "NAV_TEMPLATES": "Templates", "NAV_ZONES": "Zones", + "NEW_DISPLAY": "New Display", "NEW_PLAYLIST": "New Playlist", "NEW_TEMPLATE": "New Template", "NEXT_5_PLAYS": "Next 5 Plays", @@ -2237,6 +2249,7 @@ "ORIENTATION_LANDSCAPE": "Landscape", "ORIENTATION_PORTRAIT": "Portrait", "ORIENTATION_SQUARE": "Square", + "ORIENTATION_UNSPECIFIED": "Unspecified", "PARENT_REQUIRED": "Parent group is required", "PERMISSIONS": "Permissions", "PERM_CREATE": "Create", @@ -2290,6 +2303,7 @@ "RECURRING_SCHEDULE": "Recurring schedule", "REMOVE_DISPLAY": "Remove display", "REMOVE_DISPLAY_FROM_PLAYLIST": "Remove display {{ name }} from playlist", + "REMOVE_DISPLAY_ZONE": "Remove zone {{ name }} from display", "REMOVE_FROM_PLAYLIST": "Remove from Playlist", "REMOVE_GROUP_TOOLTIP": "Remove group", "REMOVE_PLAYLIST_FROM_DISPLAY": "Remove playlist {{ name }} from display", @@ -2370,8 +2384,10 @@ "SVC_DISPLAY_ADDED_PLAYLIST": "Display added to playlist", "SVC_DISPLAY_ADDED_ZONE": "Display added to zone", "SVC_DISPLAY_IN_ZONE": "Display already assigned to this zone.", + "SVC_DISPLAY_REMOVED": "Display removed", "SVC_DISPLAY_REMOVED_PLAYLIST": "Display removed from playlist", "SVC_DISPLAY_REMOVED_ZONE": "Display removed from zone", + "SVC_DISPLAY_SAVED": "Display saved", "SVC_ERR_ADD_USER": "Error adding group user", "SVC_ERR_ADD_ZONE": "Error adding group zone", "SVC_ERR_CONVERT_IMAGE": "Unable to convert image", @@ -2390,9 +2406,11 @@ "SVC_MEDIA_SHARED": "Media shared", "SVC_NO_APPROVERS": "Unable to load playlist approvers.", "SVC_NO_APPROVE_PLAYLISTS": "You cannot approve playlists in this group.", + "SVC_NO_CREATE_DISPLAYS": "You cannot create displays in this group.", "SVC_NO_CREATE_MEDIA": "You cannot create media in this group.", "SVC_NO_CREATE_PLAYLISTS": "You cannot create playlists in this group.", "SVC_NO_CREATE_TEMPLATES": "You cannot create templates in this group.", + "SVC_NO_DELETE_DISPLAYS": "Only system administrators can delete displays.", "SVC_NO_DELETE_MEDIA": "You cannot delete media in this group.", "SVC_NO_DELETE_PLAYLISTS": "You cannot delete playlists in this group.", "SVC_NO_DELETE_TEMPLATES": "You cannot delete templates in this group.", @@ -2401,6 +2419,7 @@ "SVC_NO_MANAGE_GROUP": "You cannot manage this signage group.", "SVC_NO_SHARE_ITEMS": "You cannot share items from this group.", "SVC_NO_UPDATE_ASSIGNMENTS": "You cannot update signage assignments in this group.", + "SVC_NO_UPDATE_DISPLAYS": "You cannot update displays in this group.", "SVC_NO_UPDATE_MEDIA": "You cannot update media in this group.", "SVC_NO_UPDATE_PLAYLISTS": "You cannot update playlists in this group.", "SVC_NO_UPDATE_TEMPLATES": "You cannot update templates in this group.", @@ -2415,6 +2434,7 @@ "SVC_PLAYLIST_SHARED": "Playlist shared", "SVC_PLAYLIST_UPDATED": "Playlist updated", "SVC_REMOVE_GROUP_TITLE": "Remove signage group?", + "SVC_REMOVE_DISPLAY_TITLE": "Remove display?", "SVC_REMOVE_MEDIA_TITLE": "Remove media?", "SVC_REMOVE_NAMED_FROM_GROUP": "Remove \"{{ name }}\" from this group?", "SVC_REMOVE_PLAYLIST_TITLE": "Remove playlist?", From c688a308b9efe5568c8d912b38fcf62cdfbd3185 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 24 Aug 2026 11:55:23 +1000 Subject: [PATCH 368/392] chore(workplace): tweak landing upcoming --- .../src/app/landing-new/landing-upcoming-booking.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a526b1dba22..982e3c65959 100644 --- a/apps/workplace/src/app/landing-new/landing-upcoming-booking.component.ts +++ b/apps/workplace/src/app/landing-new/landing-upcoming-booking.component.ts @@ -346,7 +346,7 @@ export class LandingUpcomingBookingComponent extends AsyncHandler { const event = this.nextEvent(); if (!event) return ''; if (event instanceof Booking) { - return event.description || event.title || event.asset_name || 'Booking'; + return event.title || event.description || event.asset_name || 'Booking'; } return event.title || 'Event'; }); From 3a7b851a797208c07b28cb2e66a0a78b7a0a7822 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 24 Aug 2026 13:55:38 +1000 Subject: [PATCH 369/392] fix(workplace): update unallocated parking actions --- .../landing-upcoming-booking.component.ts | 66 ++++++++++++------- ...landing-upcoming-booking.component.spec.ts | 46 +++++++++++++ 2 files changed, 89 insertions(+), 23 deletions(-) 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 index 982e3c65959..0951c5440cc 100644 --- a/apps/workplace/src/app/landing-new/landing-upcoming-booking.component.ts +++ b/apps/workplace/src/app/landing-new/landing-upcoming-booking.component.ts @@ -128,38 +128,48 @@ import { ScheduleStateService } from '../schedule/schedule-state.service'; class="flex w-full flex-col items-end space-y-2 sm:flex-1" >
- + @if (showCheckin()) { + + }
@@ -301,6 +311,15 @@ export class LandingUpcomingBookingComponent extends AsyncHandler { 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 = @@ -310,6 +329,7 @@ export class LandingUpcomingBookingComponent extends AsyncHandler { ? event?.can_check_in : event?.can_check_in; return ( + this.showCheckin() && can_checkin && (event instanceof Booking || event?.extension_data?.shared_event || 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 index de40571cfd1..55b5994d8d5 100644 --- a/apps/workplace/src/tests/landing/landing-upcoming-booking.component.spec.ts +++ b/apps/workplace/src/tests/landing/landing-upcoming-booking.component.spec.ts @@ -120,6 +120,52 @@ describe('LandingUpcomingBookingComponent', () => { 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[mattooltip="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[mattooltip="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([ From 5d4c548a9b41837b7cbdcba3ed80416f9b961ffb Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 26 Aug 2026 12:39:10 +1000 Subject: [PATCH 370/392] fix(concierge): show parking allocation groups --- .../parking-bookings-list.component.ts | 24 ++++++++ .../parking-bookings-week-view.component.ts | 26 ++++++++- .../parking-bookings-list.component.spec.ts | 57 ++++++++++++++++++- ...rking-bookings-week-view.component.spec.ts | 55 +++++++++++++++++- shared/assets/locale/en-AU.json | 3 +- shared/assets/locale/en-US.json | 3 +- 6 files changed, 163 insertions(+), 5 deletions(-) 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 465a5cce809..8434c15ae61 100644 --- a/apps/concierge/src/app/parking/parking-bookings-list.component.ts +++ b/apps/concierge/src/app/parking/parking-bookings-list.component.ts @@ -58,6 +58,7 @@ interface ParkingBookingColumnTemplates { 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; @@ -69,6 +70,7 @@ interface ParkingBookingColumnTemplates { notes_label: string; status_label: string; user_groups_label: string; + allocation_group_label: string; } @Component({ @@ -125,6 +127,7 @@ interface ParkingBookingColumnTemplates { status_template, requested_at_template, user_groups_template, + allocation_group_template, action_template, status_busy_label: 'COMMON.STATUS_BUSY' | translate, type_label: @@ -143,6 +146,9 @@ interface ParkingBookingColumnTemplates { 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" @@ -514,6 +520,11 @@ interface ParkingBookingColumnTemplates { {{ 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 }} +
+ }
- -
+ + + + @if (allow_groups()) { + + } + @if (can_book_for_others()) { + + } +
+ }
@if (active_form() === 'other') {
@@ -141,8 +162,10 @@ type FormType = 'single' | 'group' | 'other';
} +
@@ -171,6 +194,7 @@ type FormType = 'single' | 'group' | 'other'; model.update((m) => ({ ...m, date: $event })) " [ngModelOptions]="{ standalone: true }" + [range]="bookable_hours()" />
@@ -183,6 +207,7 @@ type FormType = 'single' | 'group' | 'other'; [min]="min_duration()" [custom_options]="custom_duration_options()" [step]="duration_step()" + [end_time]="bookable_hours()?.end" [use_24hr]="use_24hr()" [timezone]="timezone" [formField]="form.duration" @@ -261,6 +286,21 @@ type FormType = 'single' | 'group' | 'other';

} + @if (has_assets() && active_form() !== 'group') { +
+ + +
+ }
`, @@ -281,6 +321,7 @@ type FormType = 'single' | 'group' | 'other'; RecurrenceFieldComponent, UserSearchFieldComponent, UserListFieldComponent, + AssetListFieldComponent, ], }) export class DeskFlowDetailsComponent { @@ -289,7 +330,8 @@ export class DeskFlowDetailsComponent { private _settings = inject(SettingsService); private _dialog = inject(MatDialog); - public readonly active_form = signal('single'); + /** 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' }, @@ -306,6 +348,32 @@ export class DeskFlowDetailsComponent { }); 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, @@ -360,6 +428,11 @@ export class DeskFlowDetailsComponent { ); 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') @@ -367,8 +440,8 @@ export class DeskFlowDetailsComponent { : ''; } - public setActiveForm(form: FormType) { - this.active_form.set(form as any); + public setActiveForm(form: DeskFormType) { + this.active_form.set(form); this._booking_form.setOptions({ group: form === 'group' }); if (form === 'single') { this.model.update((m) => ({ @@ -379,15 +452,21 @@ export class DeskFlowDetailsComponent { } else if (form === 'group') { this.model.update((m) => ({ ...m, user: currentUser() })); } else { - this.model.update((m) => ({ ...m, user: null, attendees: [] })); + // 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: [], + })); } } - public setOptions(options: any) { + public setOptions(options: Partial) { this._booking_form.setOptions(options); } - public onRecurrenceChange(recurrence: any) { + public onRecurrenceChange(recurrence: BookingRecurrence) { this.model.update((m) => ({ ...m, ...recurrence })); } 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 index 8cd1d8ea81c..0813b11e72b 100644 --- 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 @@ -38,8 +38,8 @@ import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticat @for ( item of available_items() | slice - : page() * page_size() - : page() * page_size() + page_size(); + : active_page() * page_size() + : active_page() * page_size() + page_size(); track item.id ) {
  • - Page {{ page() + 1 }} of {{ max_pages() }} + Page {{ active_page() + 1 }} of {{ max_pages() }}
  • !!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(); @@ -159,11 +209,14 @@ export class DeskFlowNewComponent extends AsyncHandler implements OnInit { ...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 any); + this._booking_form.setView( + param.get('step') as BookingFlowView, + ); }), ); this.subscription( @@ -186,7 +239,7 @@ export class DeskFlowNewComponent extends AsyncHandler implements OnInit { return; } await this._waitForLoaded(); - if (!asset_id) return; + if (!asset_id || this._destroyed) return; const resource = await this._findDeskResource(asset_id); if (!resource) return; const building = resource.zone?.parent_id @@ -209,9 +262,39 @@ export class DeskFlowNewComponent extends AsyncHandler implements OnInit { ); } - /** Resolve once the booking form has finished its current load */ + 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() { - while (this._booking_form.loading()) { + for (let attempt = 0; attempt < 200; attempt++) { + if (this._destroyed || !this._booking_form.loading()) return; await new Promise((resolve) => setTimeout(resolve, 50)); } } @@ -310,7 +393,37 @@ export class DeskFlowNewComponent extends AsyncHandler implements OnInit { })); } + /** + * 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) => ({ @@ -333,9 +446,7 @@ export class DeskFlowNewComponent extends AsyncHandler implements OnInit { } this.loading.set(true); try { - await (this.options()?.group - ? this._booking_form.postFormForGroup() - : this._booking_form.postForm()); + await this._postBooking(); notifySuccess(i18n('APP.CONCIERGE.DESKS_BOOKING_SUCCESS')); this._router.navigate(['/book/desk/success']); } catch (e) { 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 index c13648fb309..f63fa4d7a36 100644 --- 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 @@ -14,6 +14,7 @@ import { Router } from '@angular/router'; import { BookingAsset, BookingFormService, + BookingFormValue, findNearbyFeature, } from '@placeos/bookings'; import { @@ -245,13 +246,26 @@ export class AutoAssignedDeskModalComponent 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' }); @@ -391,12 +405,7 @@ export class AutoAssignedDeskModalComponent assigned_desk = level_with_most_desks[1][0]; } - this._state.model.update((m) => ({ - ...m, - asset_id: assigned_desk.id, - resources: [assigned_desk], - })); - + // The desk goes into the shared form only on confirm() this.assigned_desk.set(assigned_desk); this.loading.set(''); @@ -454,8 +463,6 @@ export class AutoAssignedDeskModalComponent const assigned = this.assigned_desk(); if (!assigned) return []; - console.log('Assigned:', assigned); - return [ { location: assigned.map_id, @@ -484,6 +491,7 @@ export class AutoAssignedDeskModalComponent } else { await this._state.postForm(); } + this._booked = true; await this._router.navigate(['/book', 'desk', 'success']); this._dialog_ref?.close(true); } catch (e) { 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 index b91896d396e..1ffaec258bd 100644 --- 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 @@ -18,9 +18,12 @@ 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', @@ -60,7 +63,10 @@ import { - + @if (allow_all_day()) {
    -
    - - -
    + @if (!allow_multiday()) { +
    + + +
    + }
    + @if (allow_multiday()) { +
    +
    + + +
    +
    + + +
    +
    + } + @if (can_book_for_others()) { +
    + + +
    + } @if (allow_recurrence()) {
    @let capacity = options()?.capacity || -1; -
    - @if (capacity === -1) { - task_alt - } - - - - - + @if (capacity === option.value) { + task_alt + } + + }
    @@ -272,6 +258,7 @@ import { RouterModule, TranslatePipe, MatCheckboxModule, + HostSelectFieldComponent, ], }) export class MeetingFlowDetailsComponent { @@ -295,10 +282,27 @@ export class MeetingFlowDetailsComponent { 'events.custom_duration_options', [], ); - public readonly available_days = settingSignal( - 'events.available_period', + /** 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, @@ -328,7 +332,7 @@ export class MeetingFlowDetailsComponent { () => this.form_value().all_day || this.form.date().disabled(), ); public readonly setCapacity = (capacity: number) => { - this._event_form.setFilters({ capacity }); + this._event_form.setFilters(roomCapacityFilters(capacity)); }; public readonly allow_recurrence = computed( 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 index bd32728bf96..be83e20cc28 100644 --- 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 @@ -24,6 +24,7 @@ import { currentUser, formatRecurrence, fromEventRecurrence, + getInvalidSignalFields, i18n, notifyError, notifySuccess, @@ -43,6 +44,12 @@ import { } 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: ` @@ -205,26 +212,36 @@ import { FindAvailabilityModalComponent } from '@placeos/users';
    -

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

    - -

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

    {{ 'CALENDAR_EVENT.CATERING' | translate }} @@ -238,6 +255,7 @@ import { FindAvailabilityModalComponent } from '@placeos/users'; zone_id: form_value().resources?.length ? form_value().resources[0]?.level?.parent_id : '', + resources: form_value().resources, }" > @if (form_value().catering?.length && has_codes()) { @@ -262,7 +280,7 @@ import { FindAvailabilityModalComponent } from '@placeos/users'; standalone: true, }" [placeholder]=" - 'CALENDAR_EVENT.CATERING_CHARGE_CODE_SEACH' + 'CALENDAR_EVENT.CATERING_CHARGE_CODE_SEARCH' | translate " /> @@ -418,8 +436,48 @@ export class MeetingFlowOptionsComponent { '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 = []; + 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; @@ -474,7 +532,7 @@ export class MeetingFlowOptionsComponent { ); } - public getSpaceLocation(space: any): string { + public getSpaceLocation(space: SpaceLocation | null): string { if (!space) return ''; const parts: string[] = []; @@ -482,7 +540,9 @@ export class MeetingFlowOptionsComponent { // Get level information if (space.level) { const level = this._org.levelWithID([ - space.level.id || space.level, + typeof space.level === 'string' + ? space.level + : space.level.id, ]); if (level) { parts.push(level.display_name || level.name); @@ -512,6 +572,12 @@ export class MeetingFlowOptionsComponent { 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 && @@ -519,6 +585,23 @@ export class MeetingFlowOptionsComponent { ) { 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( { 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 index 90657b3cc44..dcc4b2ada50 100644 --- 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 @@ -29,8 +29,8 @@ import { EventFormService } from '@placeos/events'; @for ( space of available_spaces() | slice - : page() * page_size() - : page() * page_size() + page_size(); + : active_page() * page_size() + : active_page() * page_size() + page_size(); track space.id ) {
  • - Page {{ page() + 1 }} of {{ max_pages() }} + Page {{ active_page() + 1 }} of {{ max_pages() }}
  • + } + @if (allow_multiday()) { + + + @if (!field('all_day')) { + + -
    + } } @if (view() === 'list') { @@ -489,7 +529,7 @@ import { MeetingFlowSpaceMapComponent } from './meeting-flow-space-map.component @for (opt of capacity_options; track opt.value) { @@ -500,7 +540,7 @@ import { MeetingFlowSpaceMapComponent } from './meeting-flow-space-map.component - + @if (allow_all_day()) { - + } +
    + } + @if (allow_multiday()) { + + + @if (!field('all_day')) { + + -
    + } } @if (view() === 'list') { ('list'); public readonly filters_open = signal(false); - public readonly capacity_options = [ - { value: -1, label: 'COMMON.CAPACITY_ANY' }, - { value: 1, label: 'CALENDAR_EVENT.ROOM_SIZE_1_2' }, - { value: 3, label: 'CALENDAR_EVENT.ROOM_SIZE_3_4' }, - { value: 5, label: 'CALENDAR_EVENT.ROOM_SIZE_5_8' }, - { value: 9, label: 'CALENDAR_EVENT.ROOM_SIZE_9_PLUS' }, - ]; + /** 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; @@ -726,7 +813,10 @@ export class MeetingFlowSpaceSelectComponent { 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) => this._event_form.setFilters(f); + 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; @@ -827,7 +917,7 @@ export class MeetingFlowSpaceSelectComponent { public toggleSpace(space: Space) { const resources = this.field('resources') || []; - if (this._settings.get('app.events.allow_multiple_spaces')) { + if (multipleSpacesEnabled(this._settings)) { const new_resources = resources.find(({ id }) => id === space.id) ? resources.filter(({ id }) => id !== space.id) : [...resources, space]; 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 index a25e89e03a8..51f8c3d56ca 100644 --- 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 @@ -190,7 +190,11 @@ export class MeetingFlowNewComponent extends AsyncHandler implements OnInit { 'route.params', this._route.paramMap.subscribe((param) => { if (param.has('step')) - this._event_form.setView(param.get('step') as any); + this._event_form.setView( + param.get('step') as Parameters< + EventFormService['setView'] + >[0], + ); }), ); this.subscription( @@ -198,7 +202,7 @@ export class MeetingFlowNewComponent extends AsyncHandler implements OnInit { this._route.queryParamMap.subscribe((params) => { if (params.has('view')) { if (Number.isNaN(+params.get('view'))) return; - this.view.update((o) => +params.get('view')); + 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 00000000000..1515136719e --- /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/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 7f4495d19e6..c34a405d67b 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 @@ -59,14 +59,6 @@ import { import { SettingsToggleComponent } from 'libs/components/src/lib/settings-toggle.component'; import { FullscreenEmbedComponent } from '../../components/fullscreen-embed.component'; -const SHIFT_PRESETS: Record = { - day_worker: { start: 420, end: 1020 }, - day_shift_12hr: { start: 330, end: 1110 }, - night_shift_12hr: { start: 1050, end: 390 }, - half_day_am: { start: 420, end: 720 }, - half_day_pm: { start: 750, end: 1020 }, -}; - interface ParkingRequestShiftOption { id: string; name: string; @@ -130,38 +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: 'day_worker', - name: 'BOOKINGS.PARKING_SHIFT_DAY_WORKER', - start_time: SHIFT_PRESETS.day_worker.start, - end_time: SHIFT_PRESETS.day_worker.end, - }, - { - id: 'day_shift_12hr', - name: 'BOOKINGS.PARKING_SHIFT_DAY_12HR', - start_time: SHIFT_PRESETS.day_shift_12hr.start, - end_time: SHIFT_PRESETS.day_shift_12hr.end, - }, - { - id: 'night_shift_12hr', - name: 'BOOKINGS.PARKING_SHIFT_NIGHT_12HR', - start_time: SHIFT_PRESETS.night_shift_12hr.start, - end_time: SHIFT_PRESETS.night_shift_12hr.end, - }, - { - id: 'half_day_am', - name: 'BOOKINGS.PARKING_SHIFT_HALF_DAY_AM', - start_time: SHIFT_PRESETS.half_day_am.start, - end_time: SHIFT_PRESETS.half_day_am.end, - }, - { - id: 'half_day_pm', - name: 'BOOKINGS.PARKING_SHIFT_HALF_DAY_PM', - start_time: SHIFT_PRESETS.half_day_pm.start, - end_time: SHIFT_PRESETS.half_day_pm.end, - }, -]; +/** 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' }, @@ -512,7 +474,7 @@ const DEFAULT_VEHICLE_TYPE_OPTIONS: VehicleTypeOption[] = [ @if ( model().request_type === 'special' && show_special_needs() ) { - +
    @@ -533,7 +495,7 @@ const DEFAULT_VEHICLE_TYPE_OPTIONS: VehicleTypeOption[] = [