From 02b257452c0bd2e471811fd59dcc4eb4d1d1a78c Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 14 Nov 2025 16:30:18 +1100 Subject: [PATCH 1/9] feat(concierge): consolidate rooms, desks, parking spaces and lockers into the resource management section --- apps/concierge/src/app/app-routing.module.ts | 8 +- .../src/app/desks/desks-manage.component.ts | 2 +- .../src/app/lockers/locker-list.component.ts | 13 +- .../parking/parking-space-list.component.ts | 12 +- .../resource-manager-topbar.component.ts | 334 ++++++++++++++++++ .../resource-manager.component.ts | 163 +++++++++ .../resource-manager.module.ts | 12 + .../src/app/ui/app-sidebar.component.ts | 68 ++-- shared/assets/locale/en-AU.json | 7 + shared/assets/locale/en-GB.json | 8 + 10 files changed, 588 insertions(+), 39 deletions(-) create mode 100644 apps/concierge/src/app/resource-manager/resource-manager-topbar.component.ts create mode 100644 apps/concierge/src/app/resource-manager/resource-manager.component.ts create mode 100644 apps/concierge/src/app/resource-manager/resource-manager.module.ts diff --git a/apps/concierge/src/app/app-routing.module.ts b/apps/concierge/src/app/app-routing.module.ts index 0b52a37b94..69cbacd134 100644 --- a/apps/concierge/src/app/app-routing.module.ts +++ b/apps/concierge/src/app/app-routing.module.ts @@ -106,9 +106,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], diff --git a/apps/concierge/src/app/desks/desks-manage.component.ts b/apps/concierge/src/app/desks/desks-manage.component.ts index 080677a0ef..555bed216f 100644 --- a/apps/concierge/src/app/desks/desks-manage.component.ts +++ b/apps/concierge/src/app/desks/desks-manage.component.ts @@ -37,7 +37,7 @@ const QR_CODES = {}; selector: 'desks-manage', template: `
diff --git a/apps/concierge/src/app/lockers/locker-list.component.ts b/apps/concierge/src/app/lockers/locker-list.component.ts index 4caacb12ef..a83eaa6753 100644 --- a/apps/concierge/src/app/lockers/locker-list.component.ts +++ b/apps/concierge/src/app/lockers/locker-list.component.ts @@ -18,11 +18,12 @@ import { LockerStateService } from './locker-state.service'; @Component({ selector: 'locker-list', template: ` - - + +
+ `, styles: [], imports: [ 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 240a8c2330..6295fe6556 100644 --- a/apps/concierge/src/app/parking/parking-space-list.component.ts +++ b/apps/concierge/src/app/parking/parking-space-list.component.ts @@ -17,11 +17,12 @@ import { ParkingStateService } from './parking-state.service'; @Component({ selector: 'parking-space-list', template: ` - - + + + `, styles: [], imports: [ 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..aa7a9ab3d5 --- /dev/null +++ b/apps/concierge/src/app/resource-manager/resource-manager-topbar.component.ts @@ -0,0 +1,334 @@ +import { Component, OnInit, inject, input } from '@angular/core'; +import { AsyncPipe } from '@angular/common'; +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, + nextValueFrom, + notifyError, + notifyInfo, + 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_index() === 1) { + + {{ '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 }} +
+
+
+ } +
+
+
+ @if (tab_index() === 1) { + + + + } + @if (tab_index() === 3) { + + } + + +
+ `, + 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 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 labels = [ + 'APP.CONCIERGE.ROOMS_BOOKING_RULES', + 'APP.CONCIERGE.DESKS_BOOKING_RULES', + 'APP.CONCIERGE.PARKING_BOOKING_RULES', + 'APP.CONCIERGE.LOCKERS_BOOKING_RULES', + ]; + return labels[this.tab_index()]; + }; + + /** 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 + switch (this.tab_index()) { + case 0: + this._room_service.setFilters({ zones: filtered_zones }); + break; + case 1: + this._desk_service.setFilters({ zones: filtered_zones }); + break; + case 2: + this._parking_service.setOptions({ zones: filtered_zones }); + break; + case 3: + this._locker_service.setFilters({ zones: filtered_zones }); + break; + } + }; + + /** 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 + switch (this.tab_index()) { + case 0: + this._room_service.setSearchString(str); + break; + case 1: + this._desk_service.setFilters({ search: str }); + break; + case 2: + this._parking_service.setOptions({ search: str }); + break; + case 3: + this._locker_service.setSearch(str); + break; + } + }; + + public manageRestrictions() { + const types = ['room', 'desk', 'parking', 'locker']; + this._dialog.open(BookingRulesModalComponent, { + data: { type: types[this.tab_index()] }, + }); + } + + public async loadCSVData(event: InputEvent) { + const data = await loadTextFileFromInputEvent(event).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_index() === 1 && 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 + switch (this.tab_index()) { + case 0: + this._room_service.setSearchString(search); + break; + case 1: + this._desk_service.setFilters({ search }); + break; + case 2: + this._parking_service.setOptions({ search }); + break; + case 3: + this._locker_service.setSearch(search); + break; + } + } 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..a31b5058ff --- /dev/null +++ b/apps/concierge/src/app/resource-manager/resource-manager.component.ts @@ -0,0 +1,163 @@ +import { Component, OnInit, 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 } from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { first } from 'rxjs/operators'; +import { RoomListComponent } from '../room-manager/room-list.component'; +import { RoomManagementService } from '../room-manager/room-management.service'; +import { DesksManageComponent } from '../desks/desks-manage.component'; +import { DesksStateService } from '../desks/desks-state.service'; +import { ParkingSpaceListComponent } from '../parking/parking-space-list.component'; +import { ParkingStateService } from '../parking/parking-state.service'; +import { LockerListComponent } from '../lockers/locker-list.component'; +import { LockerStateService } from '../lockers/locker-state.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 (selected_tab() === 0) { + + } @else if (selected_tab() === 1) { + + } @else if (selected_tab() === 2) { + + } @else if (selected_tab() === 3) { + + } +
+
+
+ `, + 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); + + private readonly TAB_NAMES = ['rooms', 'desks', 'parking', 'lockers']; + + public readonly addButtonText = () => { + const tab_index = this.selected_tab(); + if (tab_index === 0) return 'APP.CONCIERGE.ROOMS_ADD'; + if (tab_index === 1) return 'APP.CONCIERGE.DESKS_ADD'; + if (tab_index === 2) return 'APP.CONCIERGE.PARKING_ADD'; + return 'APP.CONCIERGE.LOCKERS_ADD'; + }; + + public readonly addItem = () => { + const tab_index = this.selected_tab(); + if (tab_index === 0) this._room_service.editRoom(); + else if (tab_index === 1) this._desk_service.editDesk(); + else if (tab_index === 2) this._parking_service.editSpace(); + else this._locker_service.editLockerBank(); + }; + + public onTabChange(index: number) { + this.selected_tab.set(index); + this._router.navigate([], { + relativeTo: this._route, + queryParams: { tab: this.TAB_NAMES[index] }, + 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 tab_index = this.TAB_NAMES.indexOf(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/ui/app-sidebar.component.ts b/apps/concierge/src/app/ui/app-sidebar.component.ts index b915561178..5a508b1411 100644 --- a/apps/concierge/src/app/ui/app-sidebar.component.ts +++ b/apps/concierge/src/app/ui/app-sidebar.component.ts @@ -195,31 +195,6 @@ export class ApplicationSidebarComponent 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: 'parking-manage', - name: i18n('APP.CONCIERGE.MENU_MANAGE_PARKING'), - route: ['/book/parking/manage'], - }, - { - id: 'lockers', - name: i18n('APP.CONCIERGE.MENU_MANAGE_LOCKERS'), - route: ['/book/lockers/manage'], - }, { id: 'catering', name: i18n('APP.CONCIERGE.MENU_MANAGE_CATERING'), @@ -263,6 +238,39 @@ export class ApplicationSidebarComponent }, ], }, + { + id: 'resources', + name: i18n('APP.CONCIERGE.MENU_MANAGE_RESOURCES'), + icon: 'category', + route: ['/resource-management'], + children: [ + { + id: 'spaces', + name: i18n('APP.CONCIERGE.MENU_MANAGE_ROOMS'), + route: ['/resource-management'], + }, + { + id: 'desks', + name: i18n('APP.CONCIERGE.MENU_MANAGE_DESKS'), + route: ['/resource-management'], + }, + { + id: 'parking', + name: i18n('APP.CONCIERGE.MENU_MANAGE_PARKING'), + route: ['/resource-management'], + }, + { + id: 'parking-manage', + name: i18n('APP.CONCIERGE.MENU_MANAGE_PARKING'), + route: ['/resource-management'], + }, + { + id: 'lockers', + name: i18n('APP.CONCIERGE.MENU_MANAGE_LOCKERS'), + route: ['/resource-management'], + }, + ], + }, { id: 'assets', name: i18n('APP.CONCIERGE.MENU_ASSETS'), @@ -408,7 +416,15 @@ export class ApplicationSidebarComponent this._isFeatureAvailable(_.id)) && _.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 }; + } + return link; + }), ); if (this.filtered_links().find((_) => _.id === 'home')) { const link = this.filtered_links().find((_) => _.id === 'home'); diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 9febf6b997..c9b0421e79 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -963,6 +963,7 @@ "MENU_VISITOR_RULES": "External", "MENU_MANAGEMENT": "Facilities", "MENU_MANAGE_ZONES": "Zone Management", + "MENU_MANAGE_RESOURCES": "Resource Management", "MENU_MANAGE_REGIONS": "Region Management", "MENU_MANAGE_BUILDINGS": "Building Management", "MENU_MANAGE_LEVELS": "Level Management", @@ -1135,6 +1136,12 @@ "LEVELS_REMOVE_LOADING": "Removing level...", "LEVELS_REMOVE_SUCCESS": "Successfully removed level", "LEVELS_REMOVE_ERROR": "Failed to remove level. Error: {{ error }}", + "RESOURCES_HEADER": "Resource Management", + "TAB_ROOMS": "Rooms", + "TAB_DESKS": "Desks", + "TAB_PARKING": "Parking", + "TAB_LOCKERS": "Lockers", + "DESKS_ADD": "Add Desk", "ROOMS_HEADER": "Room Management", "ROOMS_ADD": "Add Room", "ROOMS_NEW": "New Room", diff --git a/shared/assets/locale/en-GB.json b/shared/assets/locale/en-GB.json index f4ade3e37b..a0d6dd6fb5 100644 --- a/shared/assets/locale/en-GB.json +++ b/shared/assets/locale/en-GB.json @@ -951,6 +951,8 @@ "MENU_VISITOR_BOOKINGS": "Visitor List", "MENU_VISITOR_RULES": "External", "MENU_MANAGEMENT": "Facilities", + "MENU_MANAGE_ZONES": "Zone Management", + "MENU_MANAGE_RESOURCES": "Resource Management", "MENU_MANAGE_REGIONS": "Region Management", "MENU_MANAGE_BUILDINGS": "Building Management", "MENU_MANAGE_LEVELS": "Level Management", @@ -1119,6 +1121,12 @@ "LEVELS_REMOVE_LOADING": "Removing level...", "LEVELS_REMOVE_SUCCESS": "Successfully removed level", "LEVELS_REMOVE_ERROR": "Failed to remove level. Error: {{ error }}", + "RESOURCES_HEADER": "Resource Management", + "TAB_ROOMS": "Rooms", + "TAB_DESKS": "Desks", + "TAB_PARKING": "Parking", + "TAB_LOCKERS": "Lockers", + "DESKS_ADD": "Add Desk", "ROOMS_HEADER": "Room Management", "ROOMS_ADD": "Add Room", "ROOMS_NEW": "New Room", From 93a617959eb59c36d883047a08823b4f50534172 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 14 Nov 2025 16:30:45 +1100 Subject: [PATCH 2/9] chore(concierge): consolidate reports sidebar item into one --- .../src/app/reports/reports-menu.component.ts | 128 +++++++++--------- .../src/app/reports/reports.module.ts | 4 +- .../src/app/ui/app-sidebar.component.ts | 45 +----- shared/assets/locale/en-AU.json | 1 + 4 files changed, 73 insertions(+), 105 deletions(-) diff --git a/apps/concierge/src/app/reports/reports-menu.component.ts b/apps/concierge/src/app/reports/reports-menu.component.ts index acba5bd3c5..09b04882eb 100644 --- a/apps/concierge/src/app/reports/reports-menu.component.ts +++ b/apps/concierge/src/app/reports/reports-menu.component.ts @@ -2,86 +2,85 @@ 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 +102,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 +119,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/ui/app-sidebar.component.ts b/apps/concierge/src/app/ui/app-sidebar.component.ts index 5a508b1411..8b29b0d148 100644 --- a/apps/concierge/src/app/ui/app-sidebar.component.ts +++ b/apps/concierge/src/app/ui/app-sidebar.component.ts @@ -296,51 +296,10 @@ export class ApplicationSidebarComponent icon: 'add_reaction', }, { - _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(); diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index c9b0421e79..71d0b325dc 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -984,6 +984,7 @@ "MENU_EVENTS": "Events", "MENU_SURVEYS": "Surveys", "MENU_REPORTS": "Reports", + "REPORTS_DESCRIPTION": "Select a report to view analytics and insights", "MENU_REPORT_ROOMS": "Room Bookings", "MENU_REPORT_DESKS": "Desk Bookings", "MENU_REPORT_PARKING": "Parking Reservations", From 0613252db31af9200ea2e00e8933dffd48afa3c9 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 14 Nov 2025 18:05:23 +1100 Subject: [PATCH 3/9] chore(concierge): tweaks to resource mangement --- .../resource-manager-topbar.component.ts | 81 ++++++++++++------- .../src/app/ui/app-sidebar.component.ts | 11 +-- shared/assets/locale/en-AU.json | 4 +- shared/assets/locale/en-GB.json | 4 +- 4 files changed, 64 insertions(+), 36 deletions(-) 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 index aa7a9ab3d5..98f42c306e 100644 --- a/apps/concierge/src/app/resource-manager/resource-manager-topbar.component.ts +++ b/apps/concierge/src/app/resource-manager/resource-manager-topbar.component.ts @@ -39,38 +39,65 @@ import { SearchbarComponent } from '../ui/searchbar.component'; selector: 'resource-manager-topbar', template: `
- - - @if (tab_index() === 1) { + @if (tab_index() === 1) { + + {{ 'COMMON.LEVEL_ALL' | translate }} - } - @for (level of levels | async; track level) { - -
- @if (use_region) { -
- {{ - (level.parent_id | building) - ?.display_name - }} - - + @for (level of levels | async; track level) { + +
+ @if (use_region) { +
+ {{ + (level.parent_id | building) + ?.display_name + }} + - +
+ } +
+ {{ level.display_name || level.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_index() === 1) { + } @else if (tab_index() === 1) { + + } @else if (tab_index() === 4) { + + } +
+ } @else { +
+ @if (tab_index() === 3) { + + + + {{ '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 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_index = this.tab_index(); + if (tab_index === 0) this._desk_service.setFilters({ date }); + else if (tab_index === 1) this._parking_service.setOptions({ date }); + else if (tab_index === 2) this._locker_service.setFilters({ date }); + else if (tab_index === 3) this._asset_service.setOptions({ date }); + else if (tab_index === 4) 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_index = this.tab_index(); + if (tab_index === 0) { + this._desk_service.setFilters({ zones: filtered_zones }); + } else if (tab_index === 1) { + this._parking_service.setOptions({ zones: filtered_zones }); + } else if (tab_index === 2) { + this._locker_service.setFilters({ zones: filtered_zones }); + } else if (tab_index === 4) { + 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_index = this.tab_index(); + if (tab_index === 0) { + this._desk_service.setFilters({ search: str }); + } else if (tab_index === 1) { + this._parking_service.setOptions({ search: str }); + } else if (tab_index === 2) { + this._locker_service.setSearch(str); + } else if (tab_index === 3) { + this._asset_service.setOptions({ search: str }); + } else if (tab_index === 4) { + this._visitors_service.setSearchString(str); + } + }; + + public refresh() { + const tab_index = this.tab_index(); + if (tab_index === 0) this._desk_service.refresh(); + else if (tab_index === 1) this._parking_service.startPolling(); + else if (tab_index === 2) this._locker_service.refresh(); + else if (tab_index === 3) this._asset_service.startPolling(); + else if (tab_index === 4) 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_index() === 3 && 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..33be71fc9e --- /dev/null +++ b/apps/concierge/src/app/booking-manager/booking-manager.component.ts @@ -0,0 +1,151 @@ +import { Component, OnInit, 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 } 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 (selected_tab() === 0) { + + } @else if (selected_tab() === 1) { + + } @else if (selected_tab() === 2) { + + } @else if (selected_tab() === 3) { + + } @else if (selected_tab() === 4) { + + } +
+
+
+ `, + 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); + + private readonly TAB_NAMES = [ + 'desks', + 'parking', + 'lockers', + 'assets', + 'visitors', + ]; + + public onTabChange(index: number) { + this.selected_tab.set(index); + this._router.navigate([], { + relativeTo: this._route, + queryParams: { tab: this.TAB_NAMES[index] }, + 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 tab_index = this.TAB_NAMES.indexOf(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 6c7921dce6..6d694a43b7 100644 --- a/apps/concierge/src/app/desks/desk-bookings.component.ts +++ b/apps/concierge/src/app/desks/desk-bookings.component.ts @@ -3,6 +3,7 @@ import { Component, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatRippleModule } from '@angular/material/core'; import { MatMenuModule } from '@angular/material/menu'; +import { MatProgressBar } from '@angular/material/progress-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { SettingsService } from '@placeos/common'; import { @@ -16,7 +17,7 @@ import { DesksStateService } from './desks-state.service'; @Component({ selector: 'desk-bookings', template: ` -
+
-
- -
{{ 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: [], 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 fc1d926d4a..507570901f 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 6295fe6556..be3b7dae42 100644 --- a/apps/concierge/src/app/parking/parking-space-list.component.ts +++ b/apps/concierge/src/app/parking/parking-space-list.component.ts @@ -23,128 +23,130 @@ import { ParkingStateService } from './parking-state.service'; class="w-full" /> - -
- - {{ - 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: [], diff --git a/apps/concierge/src/app/reports/reports-menu.component.ts b/apps/concierge/src/app/reports/reports-menu.component.ts index 09b04882eb..5915bed42c 100644 --- a/apps/concierge/src/app/reports/reports-menu.component.ts +++ b/apps/concierge/src/app/reports/reports-menu.component.ts @@ -61,10 +61,12 @@ const REPORT_CONFIGS = [ class="flex h-64 min-w-64 flex-col items-center justify-center rounded-xl border border-base-300 bg-base-100 p-4 shadow hover:border-info" > {{ report.icon }} -

{{ report.name }}

+

+ {{ report.name }} +

View Report

- chevron_right + chevron_right
} 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 index 98f42c306e..fd44fd821a 100644 --- a/apps/concierge/src/app/resource-manager/resource-manager-topbar.component.ts +++ b/apps/concierge/src/app/resource-manager/resource-manager-topbar.component.ts @@ -1,5 +1,5 @@ -import { Component, OnInit, inject, input } from '@angular/core'; 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'; @@ -16,9 +16,7 @@ import { downloadFile, jsonToCsv, loadTextFileFromInputEvent, - nextValueFrom, notifyError, - notifyInfo, randomInt, } from '@placeos/common'; import { @@ -38,7 +36,7 @@ import { SearchbarComponent } from '../ui/searchbar.component'; @Component({ selector: 'resource-manager-topbar', template: ` -
+
@if (tab_index() === 1) { @@ -118,7 +116,7 @@ import { SearchbarComponent } from '../ui/searchbar.component';
diff --git a/apps/concierge/src/app/ui/app-sidebar.component.ts b/apps/concierge/src/app/ui/app-sidebar.component.ts index 62f59948f0..da1beb7e21 100644 --- a/apps/concierge/src/app/ui/app-sidebar.component.ts +++ b/apps/concierge/src/app/ui/app-sidebar.component.ts @@ -130,56 +130,55 @@ 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: 'catering', + name: i18n('APP.CONCIERGE.MENU_CATERING_BOOKINGS'), + icon: 'restaurant', + route: ['/book/catering/orders'], + }, { id: 'facilities', name: i18n('APP.CONCIERGE.MENU_MANAGEMENT'), @@ -190,6 +189,11 @@ export class ApplicationSidebarComponent // name: 'Building Map', // route: ['/facilities'], // }, + { + id: 'visitor-rules', + name: i18n('APP.CONCIERGE.MENU_VISITOR_RULES'), + route: ['/book/visitors/rules'], + }, { id: 'catering', name: i18n('APP.CONCIERGE.MENU_MANAGE_CATERING'), @@ -272,12 +276,6 @@ export class ApplicationSidebarComponent }, ], }, - { - 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'), @@ -383,6 +381,11 @@ export class ApplicationSidebarComponent 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 }; + } return link; }), ); diff --git a/apps/concierge/src/app/visitors/guest-listing.component.ts b/apps/concierge/src/app/visitors/guest-listing.component.ts index c7935ad8ef..fdee5ded8e 100644 --- a/apps/concierge/src/app/visitors/guest-listing.component.ts +++ b/apps/concierge/src/app/visitors/guest-listing.component.ts @@ -23,6 +23,7 @@ import { MatRippleModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatMenuModule } from '@angular/material/menu'; +import { MatProgressBar } from '@angular/material/progress-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { IconComponent, @@ -36,98 +37,100 @@ import { VisitorsStateService } from './visitors-state.service'; @Component({ selector: 'guest-listings', template: ` - +
+ +
@if (!row?.checked_in && row.checked_out_at) {
Date: Fri, 14 Nov 2025 19:15:51 +1100 Subject: [PATCH 5/9] chore(concierge): consolidate email, poi, urls and contacts --- apps/concierge/src/app/app-routing.module.ts | 15 +- .../email-templates-list.component.ts | 62 ++--- .../settings-manager.component.ts | 199 ++++++++++++++ .../settings-manager.module.ts | 12 + .../emergency-contacts-list.component.ts | 254 ++++++++++++++++++ apps/concierge/src/app/staff/staff.module.ts | 6 +- .../src/app/ui/app-sidebar.component.ts | 106 ++++---- shared/assets/locale/en-AU.json | 6 + 8 files changed, 568 insertions(+), 92 deletions(-) create mode 100644 apps/concierge/src/app/settings-manager/settings-manager.component.ts create mode 100644 apps/concierge/src/app/settings-manager/settings-manager.module.ts create mode 100644 apps/concierge/src/app/staff/emergency-contacts-list.component.ts diff --git a/apps/concierge/src/app/app-routing.module.ts b/apps/concierge/src/app/app-routing.module.ts index aead13960d..6f44961ba1 100644 --- a/apps/concierge/src/app/app-routing.module.ts +++ b/apps/concierge/src/app/app-routing.module.ts @@ -161,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/email-templates/email-templates-list.component.ts b/apps/concierge/src/app/email-templates/email-templates-list.component.ts index 4af3edbfa0..5267011faf 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, Input, inject } from '@angular/core'; import { MatMenuModule } from '@angular/material/menu'; import { RouterModule } from '@angular/router'; import { @@ -23,34 +23,35 @@ import { @Component({ selector: 'email-templates-list', - template: `
-
-

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

-
- - -
- {{ 'APP.CONCIERGE.EMAIL_TEMPLATES_ADD' | translate }} -
- add -
-
-
-
+ template: ` +
+ @if (!hide_header) { +
+

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

+
+ + +
+ {{ 'APP.CONCIERGE.EMAIL_TEMPLATES_ADD' | translate }} +
+ add +
+
+ } + +
+
+

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

+
+ +
+
+ + + + + + +
+ @if (selected_tab() === 0) { + + } @else if (selected_tab() === 1) { + + } @else if (selected_tab() === 2) { +
+ + + +
+ + } @else if (selected_tab() === 3) { + + } +
+
+
+ `, + 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, + FormsModule, + MatFormFieldModule, + MatInputModule, + ], +}) +export class SettingsManagerComponent extends AsyncHandler implements OnInit { + private readonly _poi_service = inject(POIManagementService); + private readonly _url_service = inject(UrlManagementService); + 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); + + private readonly TAB_NAMES = [ + 'emergency-contacts', + 'email-templates', + 'url-management', + 'poi', + ]; + + public readonly addButtonText = () => { + const tab_index = this.selected_tab(); + if (tab_index === 0) return 'APP.CONCIERGE.CONTACTS_ADD'; + if (tab_index === 1) return 'APP.CONCIERGE.EMAIL_TEMPLATES_ADD'; + if (tab_index === 2) return 'APP.CONCIERGE.URLS_ADD'; + return 'APP.CONCIERGE.POI_ADD'; + }; + + public readonly addItem = () => { + const tab_index = this.selected_tab(); + if (tab_index === 0) { + const ref = this._dialog.open(EmergencyContactModalComponent, {}); + ref.afterClosed().subscribe(() => this._change.next(Date.now())); + } else if (tab_index === 1) { + this._router.navigate(['/email-templates/manage']); + } else if (tab_index === 2) { + this._url_service.editURL(); + } else { + this._poi_service.editPointOfInterest(); + } + }; + + public updateUrlSearch(value: string) { + this._url_service.setSearchString(value); + } + + public onTabChange(index: number) { + this.selected_tab.set(index); + this._router.navigate([], { + relativeTo: this._route, + queryParams: { tab: this.TAB_NAMES[index] }, + 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 tab_index = this.TAB_NAMES.indexOf(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..a7527e2b05 --- /dev/null +++ b/apps/concierge/src/app/staff/emergency-contacts-list.component.ts @@ -0,0 +1,254 @@ +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 { EmergencyContact } from './emergency-contacts.component'; +import { EmergencyContactModalComponent } from './emergency-contact-modal.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 da1beb7e21..58ec61a392 100644 --- a/apps/concierge/src/app/ui/app-sidebar.component.ts +++ b/apps/concierge/src/app/ui/app-sidebar.component.ts @@ -180,69 +180,69 @@ export class ApplicationSidebarComponent route: ['/book/catering/orders'], }, { - id: 'facilities', - name: i18n('APP.CONCIERGE.MENU_MANAGEMENT'), - icon: 'place', + id: 'visitor-rules', + name: i18n('APP.CONCIERGE.MENU_VISITOR_RULES'), + icon: 'policy', + route: ['/book/visitors/rules'], + }, + { + id: 'catering-menu', + name: i18n('APP.CONCIERGE.MENU_MANAGE_CATERING'), + icon: 'restaurant_menu', + route: ['/book/catering/menu'], + }, + { + id: 'points', + name: i18n('APP.CONCIERGE.MENU_MANAGE_POINTS'), + icon: 'loyalty', + route: ['/points-management'], + }, + { + id: 'signage', + name: i18n('APP.CONCIERGE.MENU_MANAGE_SIGNAGE'), + icon: 'tv', + route: ['/signage'], + }, + { + id: 'deals-n-offers', + name: i18n('APP.CONCIERGE.MENU_MANAGE_DEALS'), + icon: 'local_offer', + route: ['/deals-n-offers'], + }, + { + id: 'zones', + name: i18n('APP.CONCIERGE.MENU_MANAGE_ZONES'), + icon: 'account_tree', + route: ['/zone-management'], + }, + { + id: 'settings', + name: i18n('APP.CONCIERGE.MENU_MANAGE_SETTINGS'), + icon: 'settings', + route: ['/settings-management'], children: [ - // { - // id: 'facilities', - // name: 'Building Map', - // route: ['/facilities'], - // }, - { - id: 'visitor-rules', - name: i18n('APP.CONCIERGE.MENU_VISITOR_RULES'), - route: ['/book/visitors/rules'], - }, - { - id: 'catering', - name: i18n('APP.CONCIERGE.MENU_MANAGE_CATERING'), - route: ['/book/catering/menu'], - }, - { - 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: 'signage', - name: i18n('APP.CONCIERGE.MENU_MANAGE_SIGNAGE'), - route: ['/signage'], - }, - { - id: 'points-of-interest', - name: i18n('APP.CONCIERGE.MENU_MANAGE_MAP_FEATURES'), - route: ['/points-of-interest'], + id: 'email-templates', + name: i18n('APP.CONCIERGE.MENU_MANAGE_EMAILS'), + route: ['/settings-management'], }, { id: 'url-management', name: i18n('APP.CONCIERGE.MENU_MANAGE_URLS'), - route: ['/url-management'], + route: ['/settings-management'], }, { - id: 'email-templates', - name: i18n('APP.CONCIERGE.MENU_MANAGE_EMAILS'), - route: ['/email-templates'], - }, - { - id: 'deals-n-offers', - name: i18n('APP.CONCIERGE.MENU_MANAGE_DEALS'), - route: ['/deals-n-offers'], + id: 'points-of-interest', + name: i18n('APP.CONCIERGE.MENU_MANAGE_MAP_FEATURES'), + route: ['/settings-management'], }, ], }, - { - id: 'zones', - name: i18n('APP.CONCIERGE.MENU_MANAGE_ZONES'), - icon: 'account_tree', - route: ['/zone-management'], - }, { id: 'resources', name: i18n('APP.CONCIERGE.MENU_MANAGE_RESOURCES'), @@ -386,6 +386,11 @@ export class ApplicationSidebarComponent 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; }), ); @@ -393,11 +398,6 @@ export class ApplicationSidebarComponent 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/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 0394c6bec6..eab3d60adf 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -1082,6 +1082,12 @@ "TAB_REGIONS": "Regions", "TAB_BUILDINGS": "Buildings", "TAB_LEVELS": "Levels", + "TAB_EMERGENCY_CONTACTS": "Emergency Contacts", + "TAB_URL_MANAGEMENT": "Short URLs", + "TAB_EMAIL_TEMPLATES": "Email Templates", + "TAB_POI": "Points of Interest", + "SETTINGS_HEADER": "Organisation Settings", + "MENU_MANAGE_SETTINGS": "Organisation Settings", "REGIONS_HEADER": "Region Management", "REGIONS_ADD": "Add Region", "REGIONS_NEW": "New Region", From 7d7f5136119e002df433c598c6f4ff233528cc53 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 17 Nov 2025 12:31:21 +1100 Subject: [PATCH 6/9] chore(concierge): tweaks to sidebar feature handling --- .../src/app/ui/app-sidebar.component.ts | 88 +++++++++++++------ 1 file changed, 61 insertions(+), 27 deletions(-) diff --git a/apps/concierge/src/app/ui/app-sidebar.component.ts b/apps/concierge/src/app/ui/app-sidebar.component.ts index 58ec61a392..6496140210 100644 --- a/apps/concierge/src/app/ui/app-sidebar.component.ts +++ b/apps/concierge/src/app/ui/app-sidebar.component.ts @@ -9,7 +9,7 @@ import { currentUser, firstTruthyValueFrom, i18n, - unique, + unique, settingSignal } from '@placeos/common'; import { IconComponent } from '@placeos/components'; import { debounceTime, filter } from 'rxjs/operators'; @@ -18,7 +18,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 +108,8 @@ 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 || []; @@ -179,47 +174,49 @@ export class ApplicationSidebarComponent icon: 'restaurant', route: ['/book/catering/orders'], }, - { - id: 'visitor-rules', - name: i18n('APP.CONCIERGE.MENU_VISITOR_RULES'), - icon: 'policy', - route: ['/book/visitors/rules'], - }, { id: 'catering-menu', name: i18n('APP.CONCIERGE.MENU_MANAGE_CATERING'), icon: 'restaurant_menu', route: ['/book/catering/menu'], + admin: true, + alias: 'catering', }, { id: 'points', name: i18n('APP.CONCIERGE.MENU_MANAGE_POINTS'), icon: 'loyalty', route: ['/points-management'], + admin: true, }, { 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'], children: [ { id: 'emergency-contacts', @@ -248,6 +245,8 @@ export class ApplicationSidebarComponent name: i18n('APP.CONCIERGE.MENU_MANAGE_RESOURCES'), icon: 'category', route: ['/resource-management'], + admin: true, + alias: ['spaces', 'desks', 'parking', 'lockers'], children: [ { id: 'spaces', @@ -287,12 +286,14 @@ 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', @@ -321,22 +322,55 @@ 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() { @@ -363,7 +397,7 @@ export class ApplicationSidebarComponent ...link, children: link.children ? link.children.filter((_) => - this._isFeatureAvailable(_.id), + this._isFeatureAvailable(_), ) : null, })) @@ -371,7 +405,7 @@ export class ApplicationSidebarComponent (_) => ((!_.id || _.id === 'home' || - this._isFeatureAvailable(_.id)) && + this._isFeatureAvailable(_)) && _.route) || _.children?.length, ) From 3b2904ead2fa58596e94a44e839948aabd3ac575 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 17 Nov 2025 14:09:50 +1100 Subject: [PATCH 7/9] chore(concierge): add feature checks to tabs for booking, resource and setting manager sections --- .../booking-manager-topbar.component.ts | 58 ++-- .../booking-manager.component.ts | 118 ++++++-- .../email-templates-list.component.ts | 258 +++++++++--------- .../src/app/points/points-assets.component.ts | 146 +++++----- .../app/points/points-overview.component.ts | 149 +++++----- .../resource-manager-topbar.component.ts | 97 +++---- .../resource-manager.component.ts | 121 +++++--- .../settings-manager.component.ts | 225 +++++++++++---- .../signage-item-playlists.component.ts | 11 +- .../emergency-contacts-list.component.ts | 101 +++---- .../src/app/ui/app-sidebar.component.ts | 74 +++-- .../zone-manager/zone-manager.component.ts | 17 +- shared/assets/locale/en-AU.json | 2 + shared/assets/locale/en-GB.json | 2 + shared/assets/locale/en-US.json | 2 + 15 files changed, 829 insertions(+), 552 deletions(-) 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 index 11bf579cfc..12ec1af7ee 100644 --- a/apps/concierge/src/app/booking-manager/booking-manager-topbar.component.ts +++ b/apps/concierge/src/app/booking-manager/booking-manager-topbar.component.ts @@ -44,7 +44,7 @@ import { VisitorsStateService } from '../visitors/visitors-state.service'; [model]="search_value" (modelChange)="setSearch($event)" > - @if (tab_index() === 0) { + @if (tab_name() === 'desks') { - } @else if (tab_index() === 1) { + } @else if (tab_name() === 'parking') { - } @else if (tab_index() === 4) { + } @else if (tab_name() === 'visitors') { - - + + - -
- edit -
- {{ - 'APP.CONCIERGE.EMAIL_TEMPLATES_EDIT' - | translate - }} -
+
+ +
+
+ edit +
+ {{ + 'APP.CONCIERGE.EMAIL_TEMPLATES_EDIT' + | translate + }}
-
- - - -
+
+ + + +
`, styles: [``], imports: [ diff --git a/apps/concierge/src/app/points/points-assets.component.ts b/apps/concierge/src/app/points/points-assets.component.ts index 19ab6e0f27..e9addd90b7 100644 --- a/apps/concierge/src/app/points/points-assets.component.ts +++ b/apps/concierge/src/app/points/points-assets.component.ts @@ -27,77 +27,83 @@ export interface PointAsset { @Component({ selector: 'points-assets', template: ` - - -
- - {{ data }} +
+ + +
+ + {{ data }} + +
+
+ + + {{ data / 100 | currency: code }} p/h -
- - - - {{ data / 100 | currency: code }} p/h - - - -
- {{ data ? 'done' : 'close' }} -
-
- -
{{ data }}%
-
- -
- - -
-
+ + +
+ {{ 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 715ab68f9c..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/resource-manager/resource-manager-topbar.component.ts b/apps/concierge/src/app/resource-manager/resource-manager-topbar.component.ts index fd44fd821a..0e80f1f403 100644 --- a/apps/concierge/src/app/resource-manager/resource-manager-topbar.component.ts +++ b/apps/concierge/src/app/resource-manager/resource-manager-topbar.component.ts @@ -37,7 +37,7 @@ import { SearchbarComponent } from '../ui/searchbar.component'; selector: 'resource-manager-topbar', template: `
- @if (tab_index() === 1) { + @if (tab_name() === 'desks') { }
- @if (tab_index() === 1) { + @if (tab_name() === 'desks') { } - @if (tab_index() === 3) { + @if (tab_name() === 'lockers') {
- + + }
- - - - + @if (show_emergency_contacts()) { + + } + @if (show_email_templates()) { + + } + @if (show_url_management()) { + + } + @if (show_poi()) { + + } + @if (show_points_overview()) { + + } + @if (show_points_assets()) { + + } -
- @if (selected_tab() === 0) { +
+ @if (current_tab_name() === 'emergency-contacts') { - } @else if (selected_tab() === 1) { + } @else if (current_tab_name() === 'email-templates') { - } @else if (selected_tab() === 2) { + } @else if (current_tab_name() === 'url-management') {
- } @else if (selected_tab() === 3) { + } @else if (current_tab_name() === 'poi') { + } @else if (current_tab_name() === 'points-overview') { + + } @else if (current_tab_name() === 'points-assets') { + }
@@ -122,6 +171,8 @@ import { MatInputModule } from '@angular/material/input'; EmailTemplatesListComponent, UrlListComponent, POIListComponent, + PointsOverviewComponent, + PointsAssetsComponent, FormsModule, MatFormFieldModule, MatInputModule, @@ -130,6 +181,7 @@ import { MatInputModule } from '@angular/material/input'; 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 _dialog = inject(MatDialog); private readonly _org = inject(OrganisationService); private readonly _route = inject(ActivatedRoute); @@ -138,33 +190,90 @@ export class SettingsManagerComponent extends AsyncHandler implements OnInit { 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_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_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', + 'points-overview', + 'points-assets', ]; public readonly addButtonText = () => { - const tab_index = this.selected_tab(); - if (tab_index === 0) return 'APP.CONCIERGE.CONTACTS_ADD'; - if (tab_index === 1) return 'APP.CONCIERGE.EMAIL_TEMPLATES_ADD'; - if (tab_index === 2) return 'APP.CONCIERGE.URLS_ADD'; - return 'APP.CONCIERGE.POI_ADD'; + 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 === 'points-assets') return 'APP.CONCIERGE.POINTS_ASSETS_ADD'; + return ''; }; public readonly addItem = () => { - const tab_index = this.selected_tab(); - if (tab_index === 0) { + 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_index === 1) { + } else if (tab === 'email-templates') { this._router.navigate(['/email-templates/manage']); - } else if (tab_index === 2) { + } else if (tab === 'url-management') { this._url_service.editURL(); - } else { + } else if (tab === 'poi') { this._poi_service.editPointOfInterest(); + } else if (tab === 'points-assets') { + this._points_service.newAsset(); } }; @@ -174,11 +283,14 @@ export class SettingsManagerComponent extends AsyncHandler implements OnInit { public onTabChange(index: number) { this.selected_tab.set(index); - this._router.navigate([], { - relativeTo: this._route, - queryParams: { tab: this.TAB_NAMES[index] }, - queryParamsHandling: 'merge', - }); + const available = this.available_tabs(); + if (available[index]) { + this._router.navigate([], { + relativeTo: this._route, + queryParams: { tab: available[index].name }, + queryParamsHandling: 'merge', + }); + } } public async ngOnInit() { @@ -188,7 +300,10 @@ export class SettingsManagerComponent extends AsyncHandler implements OnInit { this._route.queryParamMap.subscribe((params) => { if (params.has('tab')) { const tab_name = params.get('tab'); - const tab_index = this.TAB_NAMES.indexOf(tab_name); + 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/signage/signage-item-playlists.component.ts b/apps/concierge/src/app/signage/signage-item-playlists.component.ts index 75b1c87271..21b2f50a40 100644 --- a/apps/concierge/src/app/signage/signage-item-playlists.component.ts +++ b/apps/concierge/src/app/signage/signage-item-playlists.component.ts @@ -153,7 +153,9 @@ const PLAYLIST_ITEM_COUNTS = signal>({}); } @else { @@ -174,7 +176,12 @@ const PLAYLIST_ITEM_COUNTS = signal>({});
diff --git a/apps/concierge/src/app/staff/emergency-contacts-list.component.ts b/apps/concierge/src/app/staff/emergency-contacts-list.component.ts index a7527e2b05..0e2bfabee1 100644 --- a/apps/concierge/src/app/staff/emergency-contacts-list.component.ts +++ b/apps/concierge/src/app/staff/emergency-contacts-list.component.ts @@ -22,60 +22,67 @@ import { import { showMetadata, updateMetadata } from '@placeos/ts-client'; import { BehaviorSubject, combineLatest } from 'rxjs'; import { filter, map, shareReplay, switchMap } from 'rxjs/operators'; -import { EmergencyContact } from './emergency-contacts.component'; 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 }} - - } - - -
-
- + search + + + + + {{ + 'APP.CONCIERGE.CONTACTS_ROLES_ALL' | translate + }} + @for ( + role of (roles | async) || []; + track role + $index + ) { + + {{ role }} + + } + + +
+
+ +
-
-
+ -
+
@@ -107,10 +108,7 @@ import { PlaceZone } from '@placeos/ts-client';
- -
diff --git a/libs/bookings/src/lib/new-parking-select-modal/new-parking-filters-display.component.ts b/libs/bookings/src/lib/new-parking-select-modal/new-parking-filters-display.component.ts index c74b6a71eb..49f8a52e77 100644 --- a/libs/bookings/src/lib/new-parking-select-modal/new-parking-filters-display.component.ts +++ b/libs/bookings/src/lib/new-parking-select-modal/new-parking-filters-display.component.ts @@ -8,7 +8,6 @@ import { SettingsService, } from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { BookingFormService } from '../booking-form.service'; @Component({ @@ -67,7 +66,7 @@ import { BookingFormService } from '../booking-form.service'; } `, ], - imports: [CommonModule, IconComponent, TranslatePipe, MatRippleModule], + imports: [CommonModule, IconComponent, MatRippleModule], }) export class NewParkingFiltersDisplayComponent extends AsyncHandler { private _event_form = inject(BookingFormService); diff --git a/libs/components/src/lib/user-controls-sidebar.component.ts b/libs/components/src/lib/user-controls-sidebar.component.ts index 0897a7cd2b..a2d346bfc6 100644 --- a/libs/components/src/lib/user-controls-sidebar.component.ts +++ b/libs/components/src/lib/user-controls-sidebar.component.ts @@ -4,7 +4,6 @@ import { CommonModule } from '@angular/common'; import { Component, inject, OnDestroy, signal, viewChild } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { IconComponent } from './icon.component'; -import { TranslatePipe } from './translate.pipe'; import { UserControlsComponent } from './user-controls.component'; @Component({ @@ -60,7 +59,6 @@ import { UserControlsComponent } from './user-controls.component'; MatRippleModule, IconComponent, UserControlsComponent, - TranslatePipe, ], }) export class UserControlsSidebarComponent implements OnDestroy { diff --git a/libs/form-fields/src/lib/user-list-field.component.ts b/libs/form-fields/src/lib/user-list-field.component.ts index e5656d2034..9c3284417a 100644 --- a/libs/form-fields/src/lib/user-list-field.component.ts +++ b/libs/form-fields/src/lib/user-list-field.component.ts @@ -50,7 +50,6 @@ import { searchGuests } from 'libs/users/src/lib/guests.fn'; import { NewUserModalComponent } from 'libs/users/src/lib/new-user-modal.component'; import { searchStaff } from 'libs/users/src/lib/staff.fn'; import { USER_DOMAIN } from 'libs/users/src/lib/user.utilities'; -import { PlaceUserPipe } from './place-user.pipe'; function validateEmail(email) { const re = @@ -237,7 +236,6 @@ const DENIED_FILE_TYPES = [ MatRippleModule, TranslatePipe, IconComponent, - PlaceUserPipe, MatTooltipModule, UserAvatarComponent, ],