+ @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: `
+
+
+
+
+
+
+ @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) {