diff --git a/apps/concierge/src/app/app-routing.module.ts b/apps/concierge/src/app/app-routing.module.ts index 0b52a37b94..6f44961ba1 100644 --- a/apps/concierge/src/app/app-routing.module.ts +++ b/apps/concierge/src/app/app-routing.module.ts @@ -41,6 +41,15 @@ const routes: Routes = [ canActivate: [AuthorisedUserGuard], canLoad: [AuthorisedUserGuard], }, + { + path: 'bookings', + loadChildren: () => + import('./booking-manager/booking-manager.module').then( + (m) => m.BookingManagerModule, + ), + canActivate: [AuthorisedUserGuard], + canLoad: [AuthorisedUserGuard], + }, { path: 'book/desks', loadChildren: () => @@ -106,9 +115,13 @@ const routes: Routes = [ }, { path: 'room-management', + redirectTo: 'resource-management', + }, + { + path: 'resource-management', loadChildren: () => - import('./room-manager/room-manager.module').then( - (m) => m.RoomManagerModule, + import('./resource-manager/resource-manager.module').then( + (m) => m.ResourceManagerModule, ), canActivate: [AuthorisedUserGuard], canLoad: [AuthorisedUserGuard], @@ -148,18 +161,17 @@ const routes: Routes = [ }, { path: 'points-of-interest', - loadChildren: () => - import('./poi-manager/poi-manager.module').then( - (m) => m.POIManagerModule, - ), - canActivate: [AuthorisedUserGuard], - canLoad: [AuthorisedUserGuard], + redirectTo: 'settings-management', }, { path: 'url-management', + redirectTo: 'settings-management', + }, + { + path: 'settings-management', loadChildren: () => - import('./url-management/url-manager.module').then( - (m) => m.UrlManagerModule, + import('./settings-manager/settings-manager.module').then( + (m) => m.SettingsManagerModule, ), canActivate: [AuthorisedUserGuard], canLoad: [AuthorisedUserGuard], diff --git a/apps/concierge/src/app/asset-manager/asset-request-list.component.ts b/apps/concierge/src/app/asset-manager/asset-request-list.component.ts index 0597a98f2a..b0d298d8d7 100644 --- a/apps/concierge/src/app/asset-manager/asset-request-list.component.ts +++ b/apps/concierge/src/app/asset-manager/asset-request-list.component.ts @@ -15,7 +15,6 @@ import { } from '@placeos/components'; import { startOfDay } from 'date-fns'; import { map } from 'rxjs/operators'; -import { DateOptionsComponent } from '../ui/date-options.component'; import { AssetManagerStateService } from './asset-manager-state.service'; import { AssetRequestDetailsComponent } from './asset-request-details.component'; import { SplitJoinPipe } from './split-join.pipe'; @@ -23,86 +22,68 @@ import { SplitJoinPipe } from './split-join.pipe'; @Component({ selector: 'app-asset-request-list', template: ` -
-
-
- {{ - 'APP.CONCIERGE.ASSETS_REQUESTS_COUNT' - | translate: { count: (requests | async)?.length } - }} -
- -
-
- -
-
+
+ +
@@ -275,7 +256,6 @@ import { SplitJoinPipe } from './split-join.pipe'; imports: [ CommonModule, MatRippleModule, - DateOptionsComponent, SimpleTableComponent, AssetRequestDetailsComponent, MatMenuModule, diff --git a/apps/concierge/src/app/booking-manager/booking-manager-topbar.component.ts b/apps/concierge/src/app/booking-manager/booking-manager-topbar.component.ts new file mode 100644 index 0000000000..12ec1af7ee --- /dev/null +++ b/apps/concierge/src/app/booking-manager/booking-manager-topbar.component.ts @@ -0,0 +1,340 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, OnInit, inject, input } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + AsyncHandler, + OrganisationService, + SettingsService, +} from '@placeos/common'; +import { + BuildingPipe, + IconComponent, + TranslatePipe, +} from '@placeos/components'; +import { combineLatest } from 'rxjs'; +import { first, map } from 'rxjs/operators'; +import { AssetManagerStateService } from '../asset-manager/asset-manager-state.service'; +import { DeskBookModalComponent } from '../desks/desk-book-modal.component'; +import { DesksStateService } from '../desks/desks-state.service'; +import { LockerStateService } from '../lockers/locker-state.service'; +import { ParkingBookingModalComponent } from '../parking/parking-booking-modal.component'; +import { ParkingStateService } from '../parking/parking-state.service'; +import { DateOptionsComponent } from '../ui/date-options.component'; +import { SearchbarComponent } from '../ui/searchbar.component'; +import { InviteVisitorModalComponent } from '../visitors/invite-visitor-modal.component'; +import { VisitorsStateService } from '../visitors/visitors-state.service'; + +@Component({ + selector: 'booking-manager-topbar', + template: ` + @if (show_header()) { +
+

+ {{ 'APP.CONCIERGE.BOOKINGS_HEADER' | translate }} +

+
+ + @if (tab_name() === 'desks') { + + } @else if (tab_name() === 'parking') { + + } @else if (tab_name() === 'visitors') { + + } +
+ } @else { +
+ @if (tab_name() === 'assets') { + + + + {{ 'COMMON.LEVEL_ALL' | translate }} + + @for (level of levels | async; track level) { + +
+ @if (use_region) { +
+ {{ + (level.parent_id | building) + ?.display_name + }} + + - + +
+ } +
+ {{ + level.display_name || level.name + }} +
+
+
+ } +
+
+ } @else { + + + @for (level of levels | async; track level) { + +
+ @if (use_region) { +
+ {{ + (level.parent_id | building) + ?.display_name + }} + + - + +
+ } +
+ {{ + level.display_name || level.name + }} +
+
+
+ } +
+
+ } +
+ + +
+ } + `, + styles: [ + ` + mat-form-field { + height: 3.25rem; + } + `, + ], + imports: [ + AsyncPipe, + MatFormFieldModule, + MatSelectModule, + BuildingPipe, + SearchbarComponent, + FormsModule, + MatTooltipModule, + MatRippleModule, + IconComponent, + TranslatePipe, + DateOptionsComponent, + ], +}) +export class BookingManagerTopbarComponent + extends AsyncHandler + implements OnInit +{ + private _desk_service = inject(DesksStateService); + private _parking_service = inject(ParkingStateService); + private _locker_service = inject(LockerStateService); + private _asset_service = inject(AssetManagerStateService); + private _visitors_service = inject(VisitorsStateService); + private _org = inject(OrganisationService); + private _route = inject(ActivatedRoute); + private _router = inject(Router); + private _dialog = inject(MatDialog); + private _settings = inject(SettingsService); + + public readonly tab_index = input.required(); + public readonly tab_name = input(''); + public readonly show_header = input.required(); + + public selected_zones: string[] | string = []; + public search_value: string = ''; + + /** List of levels for the active building */ + public readonly levels = combineLatest([ + this._org.active_building, + this._org.active_region, + ]).pipe( + map(([bld, region]) => + this.use_region + ? this._org.levelsForRegion(region) + : this._org.levelsForBuilding(bld), + ), + ); + + public get use_region() { + return !!this._settings.get('app.use_region'); + } + + /** Set filtered date */ + public readonly setDate = (date) => { + const tab = this.tab_name(); + if (tab === 'desks') this._desk_service.setFilters({ date }); + else if (tab === 'parking') this._parking_service.setOptions({ date }); + else if (tab === 'lockers') this._locker_service.setFilters({ date }); + else if (tab === 'assets') this._asset_service.setOptions({ date }); + else if (tab === 'visitors') + this._visitors_service.setFilters({ date }); + }; + + /** Update active zones */ + public readonly updateZones = (zones: string[] | string) => { + const zone_array = Array.isArray(zones) ? zones : [zones]; + const filtered_zones = zone_array.filter((z) => z !== 'All'); + + this._router.navigate([], { + relativeTo: this._route, + queryParams: { zone_ids: filtered_zones.join(',') }, + queryParamsHandling: 'merge', + }); + + // Update the appropriate service based on tab + const tab = this.tab_name(); + if (tab === 'desks') { + this._desk_service.setFilters({ zones: filtered_zones }); + } else if (tab === 'parking') { + this._parking_service.setOptions({ zones: filtered_zones }); + } else if (tab === 'lockers') { + this._locker_service.setFilters({ zones: filtered_zones }); + } else if (tab === 'visitors') { + this._visitors_service.setFilters({ zones: filtered_zones }); + } + }; + + /** Set search filter */ + public readonly setSearch = (str: string) => { + // Update query params + this._router.navigate([], { + relativeTo: this._route, + queryParams: { search: str || null }, + queryParamsHandling: 'merge', + }); + + // Update the appropriate service + const tab = this.tab_name(); + if (tab === 'desks') { + this._desk_service.setFilters({ search: str }); + } else if (tab === 'parking') { + this._parking_service.setOptions({ search: str }); + } else if (tab === 'lockers') { + this._locker_service.setSearch(str); + } else if (tab === 'assets') { + this._asset_service.setOptions({ search: str }); + } else if (tab === 'visitors') { + this._visitors_service.setSearchString(str); + } + }; + + public refresh() { + const tab = this.tab_name(); + if (tab === 'desks') this._desk_service.refresh(); + else if (tab === 'parking') this._parking_service.startPolling(); + else if (tab === 'lockers') this._locker_service.refresh(); + else if (tab === 'assets') this._asset_service.startPolling(); + else if (tab === 'visitors') this._visitors_service.startPolling(); + } + + public newDeskBooking() { + const ref = this._dialog.open(DeskBookModalComponent, {}); + ref.afterClosed().subscribe((_) => { + this._desk_service.refresh(); + }); + } + + public newParkingBooking() { + this._dialog.open(ParkingBookingModalComponent, {}); + } + + public inviteVisitor() { + this._dialog.open(InviteVisitorModalComponent, {}); + } + + public async ngOnInit() { + await this._org.initialised.pipe(first((_) => _)).toPromise(); + this.subscription( + 'route.query', + this._route.queryParamMap.subscribe(async (params) => { + if (params.has('zone_ids')) { + const zone_list = (params.get('zone_ids') || '').split(','); + const zones = zone_list.filter((z) => z); + this.selected_zones = + this.tab_name() === 'assets' && zones.length + ? zones[0] + : zones; + } + + // Restore search from query params + if (params.has('search')) { + const search = params.get('search') || ''; + this.search_value = search; + } else { + this.search_value = ''; + } + }), + ); + } +} diff --git a/apps/concierge/src/app/booking-manager/booking-manager.component.ts b/apps/concierge/src/app/booking-manager/booking-manager.component.ts new file mode 100644 index 0000000000..a82ff87c0f --- /dev/null +++ b/apps/concierge/src/app/booking-manager/booking-manager.component.ts @@ -0,0 +1,213 @@ +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatTabsModule } from '@angular/material/tabs'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + AsyncHandler, + OrganisationService, + settingSignal, +} from '@placeos/common'; +import { TranslatePipe } from '@placeos/components'; +import { first } from 'rxjs/operators'; +import { AssetRequestListComponent } from '../asset-manager/asset-request-list.component'; +import { DeskBookingsComponent } from '../desks/desk-bookings.component'; +import { DesksStateService } from '../desks/desks-state.service'; +import { LockerBookingsComponent } from '../lockers/locker-bookings.component'; +import { LockerStateService } from '../lockers/locker-state.service'; +import { ParkingBookingsListComponent } from '../parking/parking-bookings-list.component'; +import { ParkingStateService } from '../parking/parking-state.service'; +import { ApplicationSidebarComponent } from '../ui/app-sidebar.component'; +import { ApplicationTopbarComponent } from '../ui/app-topbar.component'; +import { GuestListingComponent } from '../visitors/guest-listing.component'; +import { VisitorsStateService } from '../visitors/visitors-state.service'; +import { BookingManagerTopbarComponent } from './booking-manager-topbar.component'; + +@Component({ + selector: '[app-booking-manager]', + template: ` + +
+ +
+ + + @if (show_desks()) { + + } + @if (show_parking()) { + + } + @if (show_lockers()) { + + } + @if (show_assets()) { + + } + @if (show_visitors()) { + + } + + +
+ @if (current_tab_name() === 'desks') { + + } @else if (current_tab_name() === 'parking') { + + } @else if (current_tab_name() === 'lockers') { + + } @else if (current_tab_name() === 'assets') { + + } @else if (current_tab_name() === 'visitors') { + + } +
+
+
+ `, + styles: [ + ` + :host { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background-color: var(--base-100); + } + `, + ], + imports: [ + ApplicationTopbarComponent, + ApplicationSidebarComponent, + MatTabsModule, + MatRippleModule, + TranslatePipe, + BookingManagerTopbarComponent, + DeskBookingsComponent, + ParkingBookingsListComponent, + LockerBookingsComponent, + AssetRequestListComponent, + GuestListingComponent, + ], +}) +export class BookingManagerComponent extends AsyncHandler implements OnInit { + private readonly _desk_service = inject(DesksStateService); + private readonly _parking_service = inject(ParkingStateService); + private readonly _locker_service = inject(LockerStateService); + private readonly _visitors_service = inject(VisitorsStateService); + private readonly _org = inject(OrganisationService); + private readonly _route = inject(ActivatedRoute); + private readonly _router = inject(Router); + + public readonly selected_tab = signal(0); + public readonly feature_list = settingSignal('features', []); + + // Feature availability computed signals + public readonly show_desks = computed(() => + this.feature_list().includes('desks'), + ); + public readonly show_parking = computed(() => + this.feature_list().includes('parking'), + ); + public readonly show_lockers = computed(() => + this.feature_list().includes('lockers'), + ); + public readonly show_assets = computed(() => + this.feature_list().includes('assets'), + ); + public readonly show_visitors = computed(() => + this.feature_list().includes('visitors'), + ); + + // Available tabs based on features + public readonly available_tabs = computed(() => { + const tabs: Array<{ name: string; feature: string }> = []; + if (this.show_desks()) tabs.push({ name: 'desks', feature: 'desks' }); + if (this.show_parking()) + tabs.push({ name: 'parking', feature: 'parking' }); + if (this.show_lockers()) + tabs.push({ name: 'lockers', feature: 'lockers' }); + if (this.show_assets()) + tabs.push({ name: 'assets', feature: 'assets' }); + if (this.show_visitors()) + tabs.push({ name: 'visitors', feature: 'visitors' }); + return tabs; + }); + + // Current tab name based on selected index + public readonly current_tab_name = computed(() => { + const available = this.available_tabs(); + const index = this.selected_tab(); + return available[index]?.name || ''; + }); + + private readonly TAB_NAMES = [ + 'desks', + 'parking', + 'lockers', + 'assets', + 'visitors', + ]; + + public onTabChange(index: number) { + this.selected_tab.set(index); + const available = this.available_tabs(); + if (available[index]) { + this._router.navigate([], { + relativeTo: this._route, + queryParams: { tab: available[index].name }, + queryParamsHandling: 'merge', + }); + } + } + + public async ngOnInit() { + await this._org.initialised.pipe(first((_) => _)).toPromise(); + this.subscription( + 'route.query', + this._route.queryParamMap.subscribe((params) => { + if (params.has('tab')) { + const tab_name = params.get('tab'); + const available = this.available_tabs(); + const tab_index = available.findIndex( + (t) => t.name === tab_name, + ); + if (tab_index >= 0) { + this.selected_tab.set(tab_index); + } + } + }), + ); + } +} diff --git a/apps/concierge/src/app/booking-manager/booking-manager.module.ts b/apps/concierge/src/app/booking-manager/booking-manager.module.ts new file mode 100644 index 0000000000..dad83f75f6 --- /dev/null +++ b/apps/concierge/src/app/booking-manager/booking-manager.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { BookingManagerComponent } from './booking-manager.component'; + +const routes: Routes = [ + { + path: '', + component: BookingManagerComponent, + }, +]; + +@NgModule({ + imports: [BookingManagerComponent, RouterModule.forChild(routes)], +}) +export class BookingManagerModule {} diff --git a/apps/concierge/src/app/desks/desk-bookings.component.ts b/apps/concierge/src/app/desks/desk-bookings.component.ts index 5ab6479bc2..197ad3cd71 100644 --- a/apps/concierge/src/app/desks/desk-bookings.component.ts +++ b/apps/concierge/src/app/desks/desk-bookings.component.ts @@ -16,7 +16,7 @@ import { DesksStateService } from './desks-state.service'; @Component({ selector: 'desk-bookings', template: ` -
+
diff --git a/apps/concierge/src/app/email-templates/email-templates-list.component.ts b/apps/concierge/src/app/email-templates/email-templates-list.component.ts index dffd675425..b16dc8d7a0 100644 --- a/apps/concierge/src/app/email-templates/email-templates-list.component.ts +++ b/apps/concierge/src/app/email-templates/email-templates-list.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, inject } from '@angular/core'; +import { Component, inject, Input } from '@angular/core'; import { MatMenuModule } from '@angular/material/menu'; import { RouterModule } from '@angular/router'; import { @@ -23,152 +23,141 @@ import { @Component({ selector: 'email-templates-list', - template: `
-
-

- {{ 'APP.CONCIERGE.EMAIL_TEMPLATES_HEADER' | translate }} -

-
- - -
- {{ 'APP.CONCIERGE.EMAIL_TEMPLATES_ADD' | translate }} -
- add -
-
-
-
- - - -
- {{ data * 1000 | date: 'mediumDate' }} -
-
- -
- {{ (data | building)?.display_name }} - @if (!(data | building)) { - - {{ 'RESOURCE.BUILDING_EMPTY' | translate }} - - } -
-
- -
- {{ data }} - @if (!data) { - - {{ 'COMMON.TRIGGER_EMPTY' | translate }} - - } -
-
- - - - + + - -
- edit -
- {{ - 'APP.CONCIERGE.EMAIL_TEMPLATES_EDIT' - | translate - }} -
+
+ +
+
+ edit +
+ {{ + 'APP.CONCIERGE.EMAIL_TEMPLATES_EDIT' + | translate + }}
-
- - - -
+
+ + +
-
`, + `, styles: [``], imports: [ CommonModule, @@ -184,6 +173,8 @@ export class EmailTemplatesListComponent { private _state = inject(EmailTemplatesStateService); private _org = inject(OrganisationService); + @Input() hide_header = false; + public sending_email: string; public readonly filters = this._state.filters; public readonly templates = this._state.filtered_templates; diff --git a/apps/concierge/src/app/lockers/locker-bookings.component.ts b/apps/concierge/src/app/lockers/locker-bookings.component.ts index 51b3d98c93..bd5e757551 100644 --- a/apps/concierge/src/app/lockers/locker-bookings.component.ts +++ b/apps/concierge/src/app/lockers/locker-bookings.component.ts @@ -17,14 +17,13 @@ import { LockerStateService } from './locker-state.service'; selector: 'locker-bookings', template: ` @let more_pages = has_more_pages | async; -
- -
- -
{{ data || 1 }}u
-
- - - - - - - -
- - - - - - - - -
-
- +
+ - - @if (!data) { -
- {{ 'APP.CONCIERGE.UNASSIGNED' | translate }} -
- } - @if (data) { +
+ +
{{ data || 1 }}u
+
+ + + + + + + +
+ + + + + + + - } +
- -
-
-
- {{ 'COMMON.COLUMN' | translate }} + + + + @if (!data) { +
+ {{ 'APP.CONCIERGE.UNASSIGNED' | translate }}
-
- {{ data[0] + 1 }}u -
-
-
-
- {{ 'COMMON.ROW' | translate }} +
{{ row.assigned_name || data }}
+ @if (row.assigned_name) { +
+ {{ data }} +
+ } + + } + + +
+
+
+ {{ 'COMMON.COLUMN' | translate }} +
+
+ {{ data[0] + 1 }}u +
-
- {{ data[1] + 1 }}u +
+
+ {{ 'COMMON.ROW' | translate }} +
+
+ {{ data[1] + 1 }}u +
-
-
- -
-
-
- {{ 'COMMON.WIDTH' | translate }} + + +
+
+
+ {{ 'COMMON.WIDTH' | translate }} +
+
+ {{ data[0] }}u +
-
- {{ data[0] }}u +
+
+ {{ 'COMMON.HEIGHT' | translate }} +
+
+ {{ data[1] }}u +
-
-
- {{ 'COMMON.HEIGHT' | translate }} -
-
- {{ data[1] }}u -
+ + +
+ @if (data) { +
+ accessible +
+ }
-
- - -
+ + @if (data) {
- accessible + done
} -
-
- - @if (data) { + +
- done -
- } -
- -
- -
- - - @if (has_driver) { - -
+ + - + + + } + - } - - + +
- +
+
`, styles: [], imports: [ 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 aa0f002a88..e077ba7d2f 100644 --- a/apps/concierge/src/app/parking/parking-bookings-list.component.ts +++ b/apps/concierge/src/app/parking/parking-bookings-list.component.ts @@ -20,61 +20,65 @@ import { ParkingStateService } from './parking-state.service'; [class.opacity-0]="!(loading | async)?.includes('bookings')" class="sticky left-0 w-full" /> - +
+ +
{{ diff --git a/apps/concierge/src/app/parking/parking-space-list.component.ts b/apps/concierge/src/app/parking/parking-space-list.component.ts index 104da38b44..be3b7dae42 100644 --- a/apps/concierge/src/app/parking/parking-space-list.component.ts +++ b/apps/concierge/src/app/parking/parking-space-list.component.ts @@ -17,133 +17,137 @@ import { ParkingStateService } from './parking-state.service'; @Component({ selector: 'parking-space-list', template: ` - - - -
- - {{ - space_status[row.id]?.includes('assigned') - ? 'person' - : space_status[row.id]?.includes('reuse') - ? 'event_available' - : 'question_mark' - }} - -
-
- - - - - @if (!data) { -
- {{ 'APP.CONCIERGE.UNASSIGNED' | translate }} -
- } - @if (data) { - - } -
- -
- + + {{ + space_status[row.id]?.includes('assigned') + ? 'person' + : space_status[row.id]?.includes('reuse') + ? 'event_available' + : 'question_mark' + }} + +
+
+ -
-
-
+ + + @if (!data) { +
+ {{ 'APP.CONCIERGE.UNASSIGNED' | translate }} +
+ } + @if (data) { + + } +
+ +
+ + +
+
+
+
`, styles: [], imports: [ diff --git a/apps/concierge/src/app/parking/parking-users-list.component.ts b/apps/concierge/src/app/parking/parking-users-list.component.ts index dc24acaf15..1ba1462346 100644 --- a/apps/concierge/src/app/parking/parking-users-list.component.ts +++ b/apps/concierge/src/app/parking/parking-users-list.component.ts @@ -1,6 +1,7 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { CommonModule } from '@angular/common'; import { Component, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { FormGroup } from '@angular/forms'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; @@ -17,45 +18,47 @@ import { ParkingStateService } from './parking-state.service'; selector: 'parking-users-list', template: ` - +
+ +
- -
-
+ + +
+ {{ data ? 'done' : 'close' }} +
+
+ +
{{ data }}%
+
+ +
+ + +
+
+
`, styles: [ ` diff --git a/apps/concierge/src/app/points/points-overview.component.ts b/apps/concierge/src/app/points/points-overview.component.ts index 1ed87fcb01..742332326d 100644 --- a/apps/concierge/src/app/points/points-overview.component.ts +++ b/apps/concierge/src/app/points/points-overview.component.ts @@ -7,83 +7,90 @@ import { CounterComponent } from '@placeos/form-fields'; @Component({ selector: 'points-overview', template: ` -

- {{ 'APP.CONCIERGE.POINTS_OVERVIEW_HEADER' | translate }} -

-
-

- {{ 'APP.CONCIERGE.POINTS_VALUE_HEADER' | translate }} +
+

+ {{ 'APP.CONCIERGE.POINTS_OVERVIEW_HEADER' | translate }}

-
- {{ 'APP.CONCIERGE.POINTS_ONE_POINT' | translate }} = - - - info - -
-

-
-

- {{ 'APP.CONCIERGE.POINTS_AUTO_REWARDS' | translate }} -

-
-
- - {{ - 'APP.CONCIERGE.POINTS_REWARD_DESK' | translate - }} -
-
- - {{ - 'APP.CONCIERGE.POINTS_REWARD_ROOM' | translate - }} -
-
+
+

+ {{ 'APP.CONCIERGE.POINTS_VALUE_HEADER' | translate }} +

+
+ {{ + 'APP.CONCIERGE.POINTS_ONE_POINT' | translate + }} + = - {{ - 'APP.CONCIERGE.POINTS_REWARD_CANCEL' | translate - }} + + info +
-
- - {{ - 'APP.CONCIERGE.POINTS_REWARD_WELLNESS' | translate - }} +
+
+

+ {{ 'APP.CONCIERGE.POINTS_AUTO_REWARDS' | translate }} +

+
+
+ + {{ + 'APP.CONCIERGE.POINTS_REWARD_DESK' | translate + }} +
+
+ + {{ + 'APP.CONCIERGE.POINTS_REWARD_ROOM' | translate + }} +
+
+ + {{ + 'APP.CONCIERGE.POINTS_REWARD_CANCEL' | translate + }} +
+
+ + {{ + 'APP.CONCIERGE.POINTS_REWARD_WELLNESS' | translate + }} +
-
-
+ +
`, styles: [ ` diff --git a/apps/concierge/src/app/reports/reports-menu.component.ts b/apps/concierge/src/app/reports/reports-menu.component.ts index 52b1fdf0dd..5915bed42c 100644 --- a/apps/concierge/src/app/reports/reports-menu.component.ts +++ b/apps/concierge/src/app/reports/reports-menu.component.ts @@ -2,86 +2,87 @@ import { Component, inject } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { RouterModule } from '@angular/router'; import { SettingsService } from '@placeos/common'; -import { IconComponent } from '@placeos/components'; +import { IconComponent, TranslatePipe } from '@placeos/components'; -const DEFAULT_FEATURES = ['desks', 'spaces', 'catering', 'contact-tracing']; +const DEFAULT_FEATURES = [ + 'desks', + 'spaces', + 'parking', + 'lockers', + 'catering', + 'contact-tracing', + 'assets', + 'visitors', +]; + +const REPORT_CONFIGS = [ + { id: 'desks', route: 'desks', icon: 'room', name: 'Desks' }, + { id: 'spaces', route: 'bookings', icon: 'meeting_room', name: 'Rooms' }, + { + id: 'catering', + route: 'catering', + icon: 'room_service', + name: 'Catering', + }, + { + id: 'contact-tracing', + route: 'contact-tracing', + icon: 'connect_without_contact', + name: 'Contact Tracing', + }, + { + id: 'parking', + route: 'parking', + icon: 'local_parking', + name: 'Parking', + }, + { id: 'lockers', route: 'lockers', icon: 'lock', name: 'Lockers' }, + { id: 'assets', route: 'assets', icon: 'inventory_2', name: 'Assets' }, + { id: 'visitors', route: 'visitors', icon: 'badge', name: 'Visitors' }, +]; @Component({ selector: 'reports-menu,[reports-menu]', template: ` -
-
- @if (features.includes('desks')) { - - room -

Desks

-
-

View Report

- chevron_right -
-
- } - @if (features.includes('spaces')) { - - meeting_room -

Rooms

-
-

View Report

- chevron_right -
-
- } - @if (features.includes('catering')) { - - room_service -

Catering

-
-

View Report

- chevron_right -
-
- } - @if (features.includes('contact-tracing')) { +
+
+

+ {{ 'APP.CONCIERGE.MENU_REPORTS' | translate }} +

+

+ {{ 'APP.CONCIERGE.REPORTS_DESCRIPTION' | translate }} +

+
+
+ @for (report of available_reports; track report.id) { - connect_without_contact -

Contact Tracing

+ {{ report.icon }} +

+ {{ report.name }} +

-

View Report

- chevron_right +

View Report

+ chevron_right
} - @for (report of custom_reports; track report) { + @for (report of custom_reports; track report.id) { {{ report.icon }}

{{ report.name }}

-

View Report

- chevron_right +

View Report

+ chevron_right
} @@ -103,11 +104,12 @@ const DEFAULT_FEATURES = ['desks', 'spaces', 'catering', 'contact-tracing']; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); /* This is better for small screens, once min() is better supported */ /* grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr)); */ - gap: 1rem; + gap: 0.75rem; + max-width: 100%; } `, ], - imports: [RouterModule, IconComponent, MatRippleModule], + imports: [RouterModule, IconComponent, MatRippleModule, TranslatePipe], }) export class ReportsMenuComponent { private _settings = inject(SettingsService); @@ -119,4 +121,10 @@ export class ReportsMenuComponent { public get features() { return this._settings.get('app.reports.features') || DEFAULT_FEATURES; } + + public get available_reports() { + return REPORT_CONFIGS.filter((report) => + this.features.includes(report.id), + ); + } } diff --git a/apps/concierge/src/app/reports/reports.module.ts b/apps/concierge/src/app/reports/reports.module.ts index c1c9162f9a..4afc963c62 100644 --- a/apps/concierge/src/app/reports/reports.module.ts +++ b/apps/concierge/src/app/reports/reports.module.ts @@ -8,13 +8,14 @@ import { CustomReportComponent } from './custom-report.component'; import { ReportDesksComponent } from './desks/report-desks.component'; import { LockersReportComponent } from './lockers/lockers-report.component'; import { ParkingReportComponent } from './parking/parking-report.component'; +import { ReportsMenuComponent } from './reports-menu.component'; import { ReportsOptionsComponent } from './reports-options.component'; import { ReportsComponent } from './reports.component'; import { ReportSpacesComponent } from './spaces/report-spaces.component'; import { VisitorsReportComponent } from './visitors/visitors-report.component'; const children: Route[] = [ - { path: '', component: ReportsOptionsComponent }, + { path: '', component: ReportsMenuComponent }, { path: 'bookings', component: ReportSpacesComponent }, { path: 'desks', component: ReportDesksComponent }, { path: 'parking', component: ParkingReportComponent }, @@ -45,6 +46,7 @@ const ROUTES: Route[] = [{ path: '', component: ReportsComponent, children }]; VisitorsReportComponent, ContactTracingReportComponent, CustomReportComponent, + ReportsMenuComponent, ReportsOptionsComponent, RouterModule.forChild(ROUTES), ], diff --git a/apps/concierge/src/app/resource-manager/resource-manager-topbar.component.ts b/apps/concierge/src/app/resource-manager/resource-manager-topbar.component.ts new file mode 100644 index 0000000000..bea2bfbe39 --- /dev/null +++ b/apps/concierge/src/app/resource-manager/resource-manager-topbar.component.ts @@ -0,0 +1,352 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, OnInit, inject, input } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + AsyncHandler, + Desk, + OrganisationService, + SettingsService, + csvToJson, + downloadFile, + jsonToCsv, + loadTextFileFromInputEvent, + notifyError, + randomInt, +} from '@placeos/common'; +import { + BuildingPipe, + IconComponent, + TranslatePipe, +} from '@placeos/components'; +import { combineLatest } from 'rxjs'; +import { first, map } from 'rxjs/operators'; +import { DesksStateService } from '../desks/desks-state.service'; +import { LockerStateService } from '../lockers/locker-state.service'; +import { ParkingStateService } from '../parking/parking-state.service'; +import { RoomManagementService } from '../room-manager/room-management.service'; +import { BookingRulesModalComponent } from '../ui/booking-rules-modal.component'; +import { SearchbarComponent } from '../ui/searchbar.component'; + +@Component({ + selector: 'resource-manager-topbar', + template: ` +
+ @if (tab_name() === 'desks') { + + + + {{ 'COMMON.LEVEL_ALL' | translate }} + + @for (level of levels | async; track level) { + +
+ @if (use_region) { +
+ {{ + (level.parent_id | building) + ?.display_name + }} + - +
+ } +
+ {{ level.display_name || level.name }} +
+
+
+ } +
+
+ } @else { + + + @for (level of levels | async; track level) { + +
+ @if (use_region) { +
+ {{ + (level.parent_id | building) + ?.display_name + }} + - +
+ } +
+ {{ level.display_name || level.name }} +
+
+
+ } +
+
+ } +
+ @if (tab_name() === 'desks') { + + + + } + @if (tab_name() === 'lockers') { + + } + + +
+ `, + styles: [ + ` + mat-form-field { + height: 3.25rem; + } + `, + ], + imports: [ + AsyncPipe, + MatFormFieldModule, + MatSelectModule, + BuildingPipe, + SearchbarComponent, + FormsModule, + MatTooltipModule, + MatRippleModule, + IconComponent, + TranslatePipe, + ], +}) +export class ResourceManagerTopbarComponent + extends AsyncHandler + implements OnInit +{ + private _room_service = inject(RoomManagementService); + private _desk_service = inject(DesksStateService); + private _parking_service = inject(ParkingStateService); + private _locker_service = inject(LockerStateService); + private _org = inject(OrganisationService); + private _route = inject(ActivatedRoute); + private _router = inject(Router); + private _dialog = inject(MatDialog); + private _settings = inject(SettingsService); + + public readonly tab_index = input.required(); + public readonly tab_name = input(''); + + public selected_zones: string[] | string = []; + public search_value: string = ''; + + /** List of levels for the active building */ + public readonly levels = combineLatest([ + this._org.active_building, + this._org.active_region, + ]).pipe( + map(([bld, region]) => + this.use_region + ? this._org.levelsForRegion(region) + : this._org.levelsForBuilding(bld), + ), + ); + + public get use_region() { + return !!this._settings.get('app.use_region'); + } + + public readonly bookingRulesTooltip = () => { + const tab = this.tab_name(); + if (tab === 'rooms') return 'APP.CONCIERGE.ROOMS_BOOKING_RULES'; + if (tab === 'desks') return 'APP.CONCIERGE.DESKS_BOOKING_RULES'; + if (tab === 'parking') return 'APP.CONCIERGE.PARKING_BOOKING_RULES'; + return 'APP.CONCIERGE.LOCKERS_BOOKING_RULES'; + }; + + /** Update active zones */ + public readonly updateZones = (zones: string[] | string) => { + const zone_array = Array.isArray(zones) ? zones : [zones]; + const filtered_zones = zone_array.filter((z) => z !== 'All'); + + this._router.navigate([], { + relativeTo: this._route, + queryParams: { zone_ids: filtered_zones.join(',') }, + queryParamsHandling: 'merge', + }); + + // Update the appropriate service based on tab + const tab = this.tab_name(); + if (tab === 'rooms') { + this._room_service.setFilters({ zones: filtered_zones }); + } else if (tab === 'desks') { + this._desk_service.setFilters({ zones: filtered_zones }); + } else if (tab === 'parking') { + this._parking_service.setOptions({ zones: filtered_zones }); + } else if (tab === 'lockers') { + this._locker_service.setFilters({ zones: filtered_zones }); + } + }; + + /** Set search filter */ + public readonly setSearch = (str: string) => { + // Update query params + this._router.navigate([], { + relativeTo: this._route, + queryParams: { search: str || null }, + queryParamsHandling: 'merge', + }); + + // Update the appropriate service + const tab = this.tab_name(); + if (tab === 'rooms') { + this._room_service.setSearchString(str); + } else if (tab === 'desks') { + this._desk_service.setFilters({ search: str }); + } else if (tab === 'parking') { + this._parking_service.setOptions({ search: str }); + } else if (tab === 'lockers') { + this._locker_service.setSearch(str); + } + }; + + public manageRestrictions() { + const tab = this.tab_name(); + const type_map = { + rooms: 'room', + desks: 'desk', + parking: 'parking', + lockers: 'locker', + }; + this._dialog.open(BookingRulesModalComponent, { + data: { type: type_map[tab] || 'room' }, + }); + } + + public async loadCSVData(event: Event) { + const data = await loadTextFileFromInputEvent(event as InputEvent).catch(([m, e]) => { + notifyError(m); + throw e; + }); + try { + const list = csvToJson(data) || []; + this._desk_service.addDesks( + list.map( + (_) => + new Desk({ + ..._, + id: _.id || `desk-${randomInt(999_999)}`, + }), + ), + ); + } catch (e) { + console.error(e); + } + } + + public downloadTemplate() { + const desk: any = new Desk({ + id: 'desk-123', + name: 'Test Desk', + bookable: true, + groups: ['test-desk-group', 'desk-bookers'], + features: ['Standing Desk', 'Dual Monitor'], + }).toJSON(); + delete desk.images; + const data = jsonToCsv([desk]); + downloadFile('desk-template.csv', data); + } + + public releaseAllLockers() { + this._locker_service.releaseAllLockers(true); + } + + public async ngOnInit() { + await this._org.initialised.pipe(first((_) => _)).toPromise(); + this.subscription( + 'route.query', + this._route.queryParamMap.subscribe(async (params) => { + if (params.has('zone_ids')) { + const zone_list = (params.get('zone_ids') || '').split(','); + const zones = zone_list.filter((z) => z); + this.selected_zones = + this.tab_name() === 'desks' && zones.length + ? zones[0] + : zones; + } + + // Restore search from query params + if (params.has('search')) { + const search = params.get('search') || ''; + this.search_value = search; + // Update the appropriate service without triggering another navigation + const tab = this.tab_name(); + if (tab === 'rooms') { + this._room_service.setSearchString(search); + } else if (tab === 'desks') { + this._desk_service.setFilters({ search }); + } else if (tab === 'parking') { + this._parking_service.setOptions({ search }); + } else if (tab === 'lockers') { + this._locker_service.setSearch(search); + } + } else { + this.search_value = ''; + } + }), + ); + } +} diff --git a/apps/concierge/src/app/resource-manager/resource-manager.component.ts b/apps/concierge/src/app/resource-manager/resource-manager.component.ts new file mode 100644 index 0000000000..a3fd1f6630 --- /dev/null +++ b/apps/concierge/src/app/resource-manager/resource-manager.component.ts @@ -0,0 +1,220 @@ +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatTabsModule } from '@angular/material/tabs'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + AsyncHandler, + OrganisationService, + settingSignal, +} from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { first } from 'rxjs/operators'; +import { DesksManageComponent } from '../desks/desks-manage.component'; +import { DesksStateService } from '../desks/desks-state.service'; +import { LockerListComponent } from '../lockers/locker-list.component'; +import { LockerStateService } from '../lockers/locker-state.service'; +import { ParkingSpaceListComponent } from '../parking/parking-space-list.component'; +import { ParkingStateService } from '../parking/parking-state.service'; +import { RoomListComponent } from '../room-manager/room-list.component'; +import { RoomManagementService } from '../room-manager/room-management.service'; +import { ApplicationSidebarComponent } from '../ui/app-sidebar.component'; +import { ApplicationTopbarComponent } from '../ui/app-topbar.component'; +import { ResourceManagerTopbarComponent } from './resource-manager-topbar.component'; + +@Component({ + selector: '[app-resource-manager]', + template: ` + +
+ +
+
+

+ {{ 'APP.CONCIERGE.RESOURCES_HEADER' | translate }} +

+
+ +
+
+ + @if (show_rooms()) { + + } + @if (show_desks()) { + + } + @if (show_parking()) { + + } + @if (show_lockers()) { + + } + + +
+ @if (current_tab_name() === 'rooms') { + + } @else if (current_tab_name() === 'desks') { + + } @else if (current_tab_name() === 'parking') { + + } @else if (current_tab_name() === 'lockers') { + + } +
+
+
+ `, + styles: [ + ` + :host { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background-color: var(--base-100); + } + `, + ], + imports: [ + ApplicationTopbarComponent, + ApplicationSidebarComponent, + MatTabsModule, + MatRippleModule, + IconComponent, + TranslatePipe, + ResourceManagerTopbarComponent, + RoomListComponent, + DesksManageComponent, + ParkingSpaceListComponent, + LockerListComponent, + ], +}) +export class ResourceManagerComponent extends AsyncHandler implements OnInit { + private readonly _room_service = inject(RoomManagementService); + private readonly _desk_service = inject(DesksStateService); + private readonly _parking_service = inject(ParkingStateService); + private readonly _locker_service = inject(LockerStateService); + private readonly _org = inject(OrganisationService); + private readonly _route = inject(ActivatedRoute); + private readonly _router = inject(Router); + + public readonly selected_tab = signal(0); + public readonly feature_list = settingSignal('features', []); + + // Feature availability computed signals + public readonly show_rooms = computed( + () => + this.feature_list().includes('spaces') || + this.feature_list().includes('zones'), + ); + public readonly show_desks = computed(() => + this.feature_list().includes('desks'), + ); + public readonly show_parking = computed(() => + this.feature_list().includes('parking'), + ); + public readonly show_lockers = computed(() => + this.feature_list().includes('lockers'), + ); + + // Available tabs based on features + public readonly available_tabs = computed(() => { + const tabs: Array<{ name: string; feature: string }> = []; + if (this.show_rooms()) tabs.push({ name: 'rooms', feature: 'spaces' }); + if (this.show_desks()) tabs.push({ name: 'desks', feature: 'desks' }); + if (this.show_parking()) + tabs.push({ name: 'parking', feature: 'parking' }); + if (this.show_lockers()) + tabs.push({ name: 'lockers', feature: 'lockers' }); + return tabs; + }); + + // Current tab name based on selected index + public readonly current_tab_name = computed(() => { + const available = this.available_tabs(); + const index = this.selected_tab(); + return available[index]?.name || ''; + }); + + private readonly TAB_NAMES = ['rooms', 'desks', 'parking', 'lockers']; + + public readonly addButtonText = () => { + const tab = this.current_tab_name(); + if (tab === 'rooms') return 'APP.CONCIERGE.ROOMS_ADD'; + if (tab === 'desks') return 'APP.CONCIERGE.DESKS_ADD'; + if (tab === 'parking') return 'APP.CONCIERGE.PARKING_ADD'; + return 'APP.CONCIERGE.LOCKERS_ADD'; + }; + + public readonly addItem = () => { + const tab = this.current_tab_name(); + if (tab === 'rooms') this._room_service.editRoom(); + else if (tab === 'desks') this._desk_service.editDesk(); + else if (tab === 'parking') this._parking_service.editSpace(); + else if (tab === 'lockers') this._locker_service.editLockerBank(); + }; + + public onTabChange(index: number) { + this.selected_tab.set(index); + const available = this.available_tabs(); + if (available[index]) { + this._router.navigate([], { + relativeTo: this._route, + queryParams: { tab: available[index].name }, + queryParamsHandling: 'merge', + }); + } + } + + public async ngOnInit() { + await this._org.initialised.pipe(first((_) => _)).toPromise(); + this.subscription( + 'route.query', + this._route.queryParamMap.subscribe((params) => { + if (params.has('tab')) { + const tab_name = params.get('tab'); + const available = this.available_tabs(); + const tab_index = available.findIndex( + (t) => t.name === tab_name, + ); + if (tab_index >= 0) { + this.selected_tab.set(tab_index); + } + } + }), + ); + } +} diff --git a/apps/concierge/src/app/resource-manager/resource-manager.module.ts b/apps/concierge/src/app/resource-manager/resource-manager.module.ts new file mode 100644 index 0000000000..dba0b61623 --- /dev/null +++ b/apps/concierge/src/app/resource-manager/resource-manager.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { Route, RouterModule } from '@angular/router'; + +import { ResourceManagerComponent } from './resource-manager.component'; + +const ROUTES: Route[] = [{ path: '', component: ResourceManagerComponent }]; + +@NgModule({ + declarations: [], + imports: [ResourceManagerComponent, RouterModule.forChild(ROUTES)], +}) +export class ResourceManagerModule {} diff --git a/apps/concierge/src/app/settings-manager/settings-manager.component.ts b/apps/concierge/src/app/settings-manager/settings-manager.component.ts new file mode 100644 index 0000000000..519c41c129 --- /dev/null +++ b/apps/concierge/src/app/settings-manager/settings-manager.component.ts @@ -0,0 +1,338 @@ +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatTabsModule } from '@angular/material/tabs'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + AsyncHandler, + OrganisationService, + settingSignal, +} from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { BehaviorSubject } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { EmailTemplatesListComponent } from '../email-templates/email-templates-list.component'; +import { ParkingStateService } from '../parking/parking-state.service'; +import { ParkingUsersListComponent } from '../parking/parking-users-list.component'; +import { POIListComponent } from '../poi-manager/poi-list.component'; +import { POIManagementService } from '../poi-manager/poi-management.service'; +import { PointsAssetsComponent } from '../points/points-assets.component'; +import { PointsOverviewComponent } from '../points/points-overview.component'; +import { PointsStateService } from '../points/points-state.service'; +import { EmergencyContactModalComponent } from '../staff/emergency-contact-modal.component'; +import { EmergencyContactsListComponent } from '../staff/emergency-contacts-list.component'; +import { ApplicationSidebarComponent } from '../ui/app-sidebar.component'; +import { ApplicationTopbarComponent } from '../ui/app-topbar.component'; +import { UrlListComponent } from '../url-management/url-list.component'; +import { UrlManagementService } from '../url-management/url-management.service'; + +@Component({ + selector: '[app-settings-manager]', + template: ` + +
+ +
+
+

+ {{ 'APP.CONCIERGE.SETTINGS_HEADER' | translate }} +

+
+ @if (addButtonText()) { + + } +
+
+ + @if (show_emergency_contacts()) { + + } + @if (show_email_templates()) { + + } + @if (show_url_management()) { + + } + @if (show_poi()) { + + } + @if (show_parking_users()) { + + } + @if (show_points_overview()) { + + } + @if (show_points_assets()) { + + } + +
+ @if (current_tab_name() === 'emergency-contacts') { + + } @else if (current_tab_name() === 'email-templates') { + + } @else if (current_tab_name() === 'url-management') { +
+ + + +
+ + } @else if (current_tab_name() === 'poi') { + + } @else if (current_tab_name() === 'parking-users') { + + } @else if (current_tab_name() === 'points-overview') { + + } @else if (current_tab_name() === 'points-assets') { + + } +
+
+
+ `, + styles: [ + ` + :host { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background-color: var(--base-100); + } + `, + ], + imports: [ + ApplicationTopbarComponent, + ApplicationSidebarComponent, + MatTabsModule, + MatRippleModule, + IconComponent, + TranslatePipe, + EmergencyContactsListComponent, + EmailTemplatesListComponent, + UrlListComponent, + POIListComponent, + ParkingUsersListComponent, + PointsOverviewComponent, + PointsAssetsComponent, + FormsModule, + MatFormFieldModule, + MatInputModule, + ], +}) +export class SettingsManagerComponent extends AsyncHandler implements OnInit { + private readonly _poi_service = inject(POIManagementService); + private readonly _url_service = inject(UrlManagementService); + private readonly _points_service = inject(PointsStateService); + private readonly _parking_service = inject(ParkingStateService); + private readonly _dialog = inject(MatDialog); + private readonly _org = inject(OrganisationService); + private readonly _route = inject(ActivatedRoute); + private readonly _router = inject(Router); + + public readonly selected_tab = signal(0); + public url_search_term = ''; + private _change = new BehaviorSubject(0); + public readonly feature_list = settingSignal('features', []); + + // Feature availability computed signals + public readonly show_emergency_contacts = computed( + () => + this.feature_list().includes('emergency-contacts') || + this.feature_list().includes('internal-users'), + ); + public readonly show_email_templates = computed(() => + this.feature_list().includes('email-templates'), + ); + public readonly show_url_management = computed(() => + this.feature_list().includes('url-management'), + ); + public readonly show_poi = computed(() => + this.feature_list().includes('points-of-interest'), + ); + public readonly show_parking_users = computed(() => + this.feature_list().includes('parking'), + ); + public readonly show_points_overview = computed(() => + this.feature_list().includes('points'), + ); + public readonly show_points_assets = computed(() => + this.feature_list().includes('points'), + ); + + // Available tabs based on features + public readonly available_tabs = computed(() => { + const tabs: Array<{ name: string; feature: string }> = []; + if (this.show_emergency_contacts()) + tabs.push({ + name: 'emergency-contacts', + feature: 'emergency-contacts', + }); + if (this.show_email_templates()) + tabs.push({ name: 'email-templates', feature: 'email-templates' }); + if (this.show_url_management()) + tabs.push({ name: 'url-management', feature: 'url-management' }); + if (this.show_poi()) tabs.push({ name: 'poi', feature: 'poi' }); + if (this.show_parking_users()) + tabs.push({ name: 'parking-users', feature: 'parking' }); + if (this.show_points_overview()) + tabs.push({ name: 'points-overview', feature: 'points' }); + if (this.show_points_assets()) + tabs.push({ name: 'points-assets', feature: 'points' }); + return tabs; + }); + + // Current tab name based on selected index + public readonly current_tab_name = computed(() => { + const available = this.available_tabs(); + const index = this.selected_tab(); + return available[index]?.name || ''; + }); + + private readonly TAB_NAMES = [ + 'emergency-contacts', + 'email-templates', + 'url-management', + 'poi', + 'parking-users', + 'points-overview', + 'points-assets', + ]; + + public readonly addButtonText = () => { + const tab = this.current_tab_name(); + if (tab === 'emergency-contacts') return 'APP.CONCIERGE.CONTACTS_ADD'; + if (tab === 'email-templates') + return 'APP.CONCIERGE.EMAIL_TEMPLATES_ADD'; + if (tab === 'url-management') return 'APP.CONCIERGE.URLS_ADD'; + if (tab === 'poi') return 'APP.CONCIERGE.POI_ADD'; + if (tab === 'parking-users') return 'APP.CONCIERGE.PARKING_USER_NEW'; + if (tab === 'points-assets') return 'APP.CONCIERGE.POINTS_ASSETS_ADD'; + return ''; + }; + + public readonly addItem = () => { + const tab = this.current_tab_name(); + if (tab === 'emergency-contacts') { + const ref = this._dialog.open(EmergencyContactModalComponent, {}); + ref.afterClosed().subscribe(() => this._change.next(Date.now())); + } else if (tab === 'email-templates') { + this._router.navigate(['/email-templates/manage']); + } else if (tab === 'url-management') { + this._url_service.editURL(); + } else if (tab === 'poi') { + this._poi_service.editPointOfInterest(); + } else if (tab === 'parking-users') { + this._parking_service.editUser(); + } else if (tab === 'points-assets') { + this._points_service.newAsset(); + } + }; + + public updateUrlSearch(value: string) { + this._url_service.setSearchString(value); + } + + public onTabChange(index: number) { + this.selected_tab.set(index); + const available = this.available_tabs(); + if (available[index]) { + this._router.navigate([], { + relativeTo: this._route, + queryParams: { tab: available[index].name }, + queryParamsHandling: 'merge', + }); + } + } + + public async ngOnInit() { + await this._org.initialised.pipe(first((_) => _)).toPromise(); + this.subscription( + 'route.query', + this._route.queryParamMap.subscribe((params) => { + if (params.has('tab')) { + const tab_name = params.get('tab'); + const available = this.available_tabs(); + const tab_index = available.findIndex( + (t) => t.name === tab_name, + ); + if (tab_index >= 0) { + this.selected_tab.set(tab_index); + } + } + }), + ); + } +} diff --git a/apps/concierge/src/app/settings-manager/settings-manager.module.ts b/apps/concierge/src/app/settings-manager/settings-manager.module.ts new file mode 100644 index 0000000000..9448e9eea3 --- /dev/null +++ b/apps/concierge/src/app/settings-manager/settings-manager.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { Route, RouterModule } from '@angular/router'; + +import { SettingsManagerComponent } from './settings-manager.component'; + +const ROUTES: Route[] = [{ path: '', component: SettingsManagerComponent }]; + +@NgModule({ + declarations: [], + imports: [SettingsManagerComponent, RouterModule.forChild(ROUTES)], +}) +export class SettingsManagerModule {} diff --git a/apps/concierge/src/app/staff/emergency-contacts-list.component.ts b/apps/concierge/src/app/staff/emergency-contacts-list.component.ts new file mode 100644 index 0000000000..0e2bfabee1 --- /dev/null +++ b/apps/concierge/src/app/staff/emergency-contacts-list.component.ts @@ -0,0 +1,259 @@ +import { Clipboard } from '@angular/cdk/clipboard'; +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { + nextValueFrom, + notifySuccess, + OrganisationService, +} from '@placeos/common'; +import { + IconComponent, + openConfirmModal, + SimpleTableComponent, + TranslatePipe, +} from '@placeos/components'; +import { showMetadata, updateMetadata } from '@placeos/ts-client'; +import { BehaviorSubject, combineLatest } from 'rxjs'; +import { filter, map, shareReplay, switchMap } from 'rxjs/operators'; +import { EmergencyContactModalComponent } from './emergency-contact-modal.component'; +import { EmergencyContact } from './emergency-contacts.component'; +import { RoleManagementModalComponent } from './role-management-modal.component'; + +@Component({ + selector: 'emergency-contacts-list', + template: ` +
+
+
+ + search + + + + + {{ + 'APP.CONCIERGE.CONTACTS_ROLES_ALL' | translate + }} + @for ( + role of (roles | async) || []; + track role + $index + ) { + + {{ role }} + + } + + +
+
+ +
+
+
+ +
+ + + + +
+ @for (role of data; track role) { + + {{ role }} + + } +
+
+ +
+ + +
+
+
+ `, + styles: [ + ` + :host { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + } + `, + ], + imports: [ + CommonModule, + MatRippleModule, + IconComponent, + MatTooltipModule, + SimpleTableComponent, + MatFormFieldModule, + MatSelectModule, + MatInputModule, + FormsModule, + TranslatePipe, + ], +}) +export class EmergencyContactsListComponent { + private _org = inject(OrganisationService); + private _dialog = inject(MatDialog); + private _clipboard = inject(Clipboard); + + private _change = new BehaviorSubject(0); + + public search = ''; + public readonly role_filter = new BehaviorSubject(''); + public readonly data = combineLatest([ + this._org.active_building, + this._change, + ]).pipe( + filter(([bld]) => !!bld), + switchMap(([bld]) => showMetadata(bld.id, 'emergency_contacts')), + map(({ details }) => (details as any) || { roles: [], contacts: [] }), + shareReplay(1), + ); + public readonly roles = this.data.pipe(map((_) => _?.roles || [])); + public readonly contacts = this.data.pipe(map((_) => _?.contacts || [])); + public readonly filtered_contacts = combineLatest([ + this.contacts, + this.role_filter, + ]).pipe( + map(([list, role]) => + list.filter((_) => !role || _.roles.includes(role)), + ), + ); + + public readonly copyToClipboard = (id: string) => { + const success = this._clipboard.copy(id); + if (success) notifySuccess("User's email copied to clipboard."); + }; + + public manageRoles() { + const ref = this._dialog.open(RoleManagementModalComponent, {}); + ref.afterClosed().subscribe(() => this._change.next(Date.now())); + } + + public editContact(contact?: EmergencyContact) { + const ref = this._dialog.open(EmergencyContactModalComponent, { + data: contact, + }); + ref.afterClosed().subscribe(() => this._change.next(Date.now())); + } + + public async removeContact(contact: EmergencyContact) { + const result = await openConfirmModal( + { + title: 'Remove Emergency Contact', + content: `Are you sure you want to remove ${contact.name} from the emergency contacts?`, + icon: { content: 'delete' }, + }, + this._dialog, + ); + if (result.reason !== 'done') return; + result.loading('Removing contact...'); + const data: any = await nextValueFrom(this.data); + const new_contacts = (data?.contacts || []).filter( + (_) => _.id !== contact.id, + ); + await updateMetadata(this._org.building.id, { + name: 'emergency_contacts', + description: 'Emergency Contacts', + details: { roles: data.roles, contacts: new_contacts }, + }).toPromise(); + result.close(); + this._change.next(Date.now()); + notifySuccess('Successfully removed emergency contact.'); + } +} diff --git a/apps/concierge/src/app/staff/staff.module.ts b/apps/concierge/src/app/staff/staff.module.ts index 826908a00e..966dce506f 100644 --- a/apps/concierge/src/app/staff/staff.module.ts +++ b/apps/concierge/src/app/staff/staff.module.ts @@ -6,7 +6,11 @@ import { StaffComponent } from './staff.component'; const ROUTES: Route[] = [ { path: '', component: StaffComponent }, - { path: 'emergency-contacts', component: EmergencyContactsComponent }, + { + path: 'emergency-contacts', + redirectTo: '/settings-management', + pathMatch: 'full', + }, ]; @NgModule({ diff --git a/apps/concierge/src/app/ui/app-sidebar.component.ts b/apps/concierge/src/app/ui/app-sidebar.component.ts index 6d49b6c40f..478879b581 100644 --- a/apps/concierge/src/app/ui/app-sidebar.component.ts +++ b/apps/concierge/src/app/ui/app-sidebar.component.ts @@ -9,6 +9,7 @@ import { currentUser, firstTruthyValueFrom, i18n, + settingSignal, unique, } from '@placeos/common'; import { IconComponent } from '@placeos/components'; @@ -18,7 +19,7 @@ import { debounceTime, filter } from 'rxjs/operators'; selector: 'app-sidebar', template: `
@for (link of filtered_links(); track link.id + '' + $index) { @if (!link.children) { @@ -108,13 +109,11 @@ export class ApplicationSidebarComponent public filtered_links = signal([]); - public get feature_list() { - return this._settings.get('app.features') || []; - } - - public get feature_groups() { - return this._settings.get('app.feature_groups') || {}; - } + public readonly feature_list = settingSignal('features', []); + public readonly feature_groups = settingSignal>( + 'feature_groups', + {}, + ); public get is_admin() { const groups = currentUser().groups || []; @@ -130,145 +129,160 @@ export class ApplicationSidebarComponent await firstTruthyValueFrom(this._org.initialised); this.links = [ { + id: 'spaces', + name: i18n('APP.CONCIERGE.MENU_ROOM_BOOKINGS'), + icon: 'meeting_room', + route: ['/book/rooms'], + }, + { + id: 'bookings', name: i18n('APP.CONCIERGE.MENU_BOOKINGS'), - icon: 'add_circle', + icon: 'book_online', + route: ['/bookings'], children: [ - { - id: 'spaces', - name: i18n('APP.CONCIERGE.MENU_ROOM_BOOKINGS'), - route: ['/book/rooms'], - }, { id: 'desks', name: i18n('APP.CONCIERGE.MENU_DESK_BOOKINGS'), - route: ['/book/desks/events'], + route: ['/bookings'], }, { id: 'parking', name: i18n('APP.CONCIERGE.MENU_PARKING_BOOKINGS'), - route: ['/book/parking/events'], + route: ['/bookings'], }, { id: 'parking-bookings', name: i18n('APP.CONCIERGE.MENU_PARKING_BOOKINGS'), - route: ['/book/parking/events'], + route: ['/bookings'], }, { id: 'lockers', name: i18n('APP.CONCIERGE.MENU_LOCKER_BOOKINGS'), - route: ['/book/lockers/events'], + route: ['/bookings'], }, { id: 'assets', name: i18n('APP.CONCIERGE.MENU_ASSET_BOOKINGS'), - route: ['/book/assets/list/requests'], - }, - { - id: 'catering', - name: i18n('APP.CONCIERGE.MENU_CATERING_BOOKINGS'), - route: ['/book/catering/orders'], + route: ['/bookings'], }, { id: 'visitors', name: i18n('APP.CONCIERGE.MENU_VISITOR_BOOKINGS'), - route: ['/book/visitors'], - }, - { - id: 'visitor-rules', - name: i18n('APP.CONCIERGE.MENU_VISITOR_RULES'), - route: ['/book/visitors/rules'], + route: ['/bookings'], }, ], }, { - id: 'facilities', - name: i18n('APP.CONCIERGE.MENU_MANAGEMENT'), - icon: 'place', + id: 'catering', + name: i18n('APP.CONCIERGE.MENU_CATERING_BOOKINGS'), + icon: 'restaurant', + route: ['/book/catering/orders'], + }, + { + id: 'catering-menu', + name: i18n('APP.CONCIERGE.MENU_MANAGE_CATERING'), + icon: 'restaurant_menu', + route: ['/book/catering/menu'], + admin: true, + alias: 'catering', + }, + { + id: 'signage', + name: i18n('APP.CONCIERGE.MENU_MANAGE_SIGNAGE'), + icon: 'tv', + route: ['/signage'], + admin: true, + }, + { + id: 'deals-n-offers', + name: i18n('APP.CONCIERGE.MENU_MANAGE_DEALS'), + icon: 'local_offer', + route: ['/deals-n-offers'], + admin: true, + }, + { + id: 'zones', + name: i18n('APP.CONCIERGE.MENU_MANAGE_ZONES'), + icon: 'account_tree', + route: ['/zone-management'], + admin: true, + }, + { + id: 'settings', + name: i18n('APP.CONCIERGE.MENU_MANAGE_SETTINGS'), + icon: 'settings', + route: ['/settings-management'], + admin: true, + alias: [ + 'emergency-contacts', + 'email-templates', + 'url-management', + 'points-of-interest', + 'points', + ], children: [ - // { - // id: 'facilities', - // name: 'Building Map', - // route: ['/facilities'], - // }, - { - id: 'zones', - name: i18n('APP.CONCIERGE.MENU_MANAGE_ZONES'), - route: ['/zone-management'], - }, - { - id: 'spaces', - name: i18n('APP.CONCIERGE.MENU_MANAGE_ROOMS'), - route: ['/room-management'], - }, { - id: 'desks', - name: i18n('APP.CONCIERGE.MENU_MANAGE_DESKS'), - route: ['/book/desks/manage'], - }, - { - id: 'parking', - name: i18n('APP.CONCIERGE.MENU_MANAGE_PARKING'), - route: ['/book/parking/manage'], + id: 'emergency-contacts', + name: i18n('APP.CONCIERGE.MENU_MANAGE_CONTACTS'), + route: ['/settings-management'], }, { - id: 'parking-manage', - name: i18n('APP.CONCIERGE.MENU_MANAGE_PARKING'), - route: ['/book/parking/manage'], + id: 'email-templates', + name: i18n('APP.CONCIERGE.MENU_MANAGE_EMAILS'), + route: ['/settings-management'], }, { - id: 'lockers', - name: i18n('APP.CONCIERGE.MENU_MANAGE_LOCKERS'), - route: ['/book/lockers/manage'], + id: 'url-management', + name: i18n('APP.CONCIERGE.MENU_MANAGE_URLS'), + route: ['/settings-management'], }, { - id: 'catering', - name: i18n('APP.CONCIERGE.MENU_MANAGE_CATERING'), - route: ['/book/catering/menu'], + id: 'points-of-interest', + name: i18n('APP.CONCIERGE.MENU_MANAGE_MAP_FEATURES'), + route: ['/settings-management'], }, { id: 'points', name: i18n('APP.CONCIERGE.MENU_MANAGE_POINTS'), - route: ['/points-management'], - }, - { - id: 'emergency-contacts', - name: i18n('APP.CONCIERGE.MENU_MANAGE_CONTACTS'), - icon: 'assignment_ind', - route: ['/users/staff/emergency-contacts'], + route: ['/settings-management'], }, + ], + }, + { + id: 'resources', + name: i18n('APP.CONCIERGE.MENU_MANAGE_RESOURCES'), + icon: 'category', + route: ['/resource-management'], + admin: true, + alias: ['spaces', 'desks', 'parking', 'lockers'], + children: [ { - id: 'signage', - name: i18n('APP.CONCIERGE.MENU_MANAGE_SIGNAGE'), - route: ['/signage'], + id: 'spaces', + name: i18n('APP.CONCIERGE.MENU_MANAGE_ROOMS'), + route: ['/resource-management'], }, { - id: 'points-of-interest', - name: i18n('APP.CONCIERGE.MENU_MANAGE_MAP_FEATURES'), - route: ['/points-of-interest'], + id: 'desks', + name: i18n('APP.CONCIERGE.MENU_MANAGE_DESKS'), + route: ['/resource-management'], }, { - id: 'url-management', - name: i18n('APP.CONCIERGE.MENU_MANAGE_URLS'), - route: ['/url-management'], + id: 'parking', + name: i18n('APP.CONCIERGE.MENU_MANAGE_PARKING'), + route: ['/resource-management'], }, { - id: 'email-templates', - name: i18n('APP.CONCIERGE.MENU_MANAGE_EMAILS'), - route: ['/email-templates'], + id: 'parking-manage', + name: i18n('APP.CONCIERGE.MENU_MANAGE_PARKING'), + route: ['/resource-management'], }, { - id: 'deals-n-offers', - name: i18n('APP.CONCIERGE.MENU_MANAGE_DEALS'), - route: ['/deals-n-offers'], + id: 'lockers', + name: i18n('APP.CONCIERGE.MENU_MANAGE_LOCKERS'), + route: ['/resource-management'], }, ], }, - { - id: 'assets', - name: i18n('APP.CONCIERGE.MENU_ASSETS'), - route: ['/book/assets/list/items'], - icon: 'vibration', - }, { id: 'internal-users', name: i18n('APP.CONCIERGE.MENU_USER_LIST'), @@ -280,59 +294,20 @@ export class ApplicationSidebarComponent name: i18n('APP.CONCIERGE.MENU_EVENTS'), route: ['/entertainment/events'], icon: 'confirmation_number', + admin: true, }, { id: 'surveys', name: i18n('APP.CONCIERGE.MENU_SURVEYS'), route: ['/surveys'], icon: 'add_reaction', + admin: true, }, { - _id: 'reports', + id: 'reports', name: i18n('APP.CONCIERGE.MENU_REPORTS'), + route: ['/reports'], icon: 'analytics', - children: [ - { - id: 'booking-report', - name: i18n('APP.CONCIERGE.MENU_REPORT_ROOMS'), - route: ['/reports/bookings'], - }, - { - id: 'desk-report', - name: i18n('APP.CONCIERGE.MENU_REPORT_DESKS'), - route: ['/reports/desks'], - }, - { - id: 'parking-report', - name: i18n('APP.CONCIERGE.MENU_REPORT_PARKING'), - route: ['/reports/parking'], - }, - { - id: 'lockers-report', - name: i18n('APP.CONCIERGE.MENU_REPORT_LOCKERS'), - route: ['/reports/lockers'], - }, - { - id: 'catering-report', - name: i18n('APP.CONCIERGE.MENU_REPORT_CATERING'), - route: ['/reports/catering'], - }, - { - id: 'contact-tracing-report', - name: i18n('APP.CONCIERGE.MENU_REPORT_CONTACT_TRACING'), - route: ['/reports/contact-tracing'], - }, - { - id: 'assets-report', - name: i18n('APP.CONCIERGE.MENU_REPORT_ASSETS'), - route: ['/reports/assets'], - }, - { - id: 'visitors-report', - name: i18n('APP.CONCIERGE.MENU_REPORT_VISITORS'), - route: ['/reports/visitors'], - }, - ], }, ]; this.updateFilteredLinks(); @@ -355,22 +330,71 @@ export class ApplicationSidebarComponent this.timeout('update_links', () => this.updateFilteredLinks(), 500); } - private _isFeatureAvailable(name: string): boolean { + private _isFeatureAvailable(link: any): boolean { + const name = link.id || link._id; if (name.startsWith('*')) { return true; } - const has_feature = this.feature_list.includes(name); - const feature_groups = this.feature_groups[name] || []; + + // Use alias if provided (can be string or array), otherwise use the item's id + const aliases = link.alias + ? Array.isArray(link.alias) + ? link.alias + : [link.alias] + : [name]; + + // Check if at least one alias matches a feature + const matching_features = aliases.filter((alias) => + this.feature_list().includes(alias), + ); + + if (!matching_features.length) { + return false; + } + const groups = currentUser().groups; - if ( - has_feature && - (this.is_admin || - !feature_groups.length || - groups.find((grp) => feature_groups.includes(grp))) - ) { + + // Special handling for items marked with admin: true + if (link.admin) { + // Check if user is admin or in feature groups for any of the matching features + return ( + this.is_admin || + matching_features.some((feature_name) => { + const feature_groups = + this.feature_groups()[feature_name] || []; + return ( + feature_groups.length && + groups.find((grp) => feature_groups.includes(grp)) + ); + }) + ); + } + + // For other features: check each matching feature + // If any feature has no groups defined, allow access + // Otherwise, require admin or group membership for at least one feature + const features_with_groups = matching_features.filter( + (feature_name) => { + const feature_groups = + this.feature_groups()[feature_name] || []; + return feature_groups.length > 0; + }, + ); + + // If no features have groups defined, just having the feature is enough + if (!features_with_groups.length) { return true; } - return false; + + // If some features have groups, check admin or group membership for any of them + return ( + this.is_admin || + features_with_groups.some((feature_name) => { + const feature_groups = + this.feature_groups()[feature_name] || []; + return groups.find((grp) => feature_groups.includes(grp)); + }) + ); } public updateFilteredLinks() { @@ -397,7 +421,7 @@ export class ApplicationSidebarComponent ...link, children: link.children ? link.children.filter((_) => - this._isFeatureAvailable(_.id), + this._isFeatureAvailable(_), ) : null, })) @@ -405,20 +429,33 @@ export class ApplicationSidebarComponent (_) => ((!_.id || _.id === 'home' || - this._isFeatureAvailable(_.id)) && + this._isFeatureAvailable(_)) && _.route) || _.children?.length, - ), + ) + .map((link) => { + // Convert resources to a simple link (not a dropdown) + // but only show if at least one resource feature is enabled + if (link.id === 'resources' && link.children?.length) { + return { ...link, children: null }; + } + // Convert bookings to a simple link (not a dropdown) + // but only show if at least one booking feature is enabled + if (link.id === 'bookings' && link.children?.length) { + return { ...link, children: null }; + } + // Convert settings to a simple link (not a dropdown) + // but only show if at least one settings feature is enabled + if (link.id === 'settings' && link.children?.length) { + return { ...link, children: null }; + } + return link; + }), ); if (this.filtered_links().find((_) => _.id === 'home')) { const link = this.filtered_links().find((_) => _.id === 'home'); link.route = this._settings.get('app.default_route') || ['/']; } - if (!this.is_admin) { - this.filtered_links.update((links) => - links.filter((_) => _.id !== 'facilities'), - ); - } } public _moveActiveLinkIntoView() { diff --git a/apps/concierge/src/app/visitors/guest-listing.component.ts b/apps/concierge/src/app/visitors/guest-listing.component.ts index ae6cb22519..a5b3268743 100644 --- a/apps/concierge/src/app/visitors/guest-listing.component.ts +++ b/apps/concierge/src/app/visitors/guest-listing.component.ts @@ -37,98 +37,100 @@ import { VisitorsStateService } from './visitors-state.service'; @Component({ selector: 'guest-listings', template: ` - +
+ +
@if (!row?.checked_in && row.checked_out_at) {