From 6ccd066bb611cb3b6ca3e54f313ac5ab58e5fc4f Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 17:07:52 +1100 Subject: [PATCH 01/21] refactor(apps): remove unused components, pipes and specs Remove 37 files that no route, template, dialog or import uses in workplace, concierge, map-kiosk, visitor-kiosk and control. Remove the concierge reports menu, which never showed because /reports redirects to the desk report, and the unused virtual_concierge.prompts setting. Control uses the lib mediaDuration pipe in place of its own copy. --- apps/concierge/e2e/parking.spec.ts | 2 - .../src/app/asset-manager/duration.pipe.ts | 20 - .../building-list.component.ts | 3 - .../building-management.service.ts | 11 - .../app/day-view/dayview-topbar.component.ts | 204 ----- .../parking-requests-list.component.ts | 581 -------------- .../parking-requests-week-view.component.ts | 426 ----------- ...parking-special-request-modal.component.ts | 88 --- .../region-management.service.ts | 11 - .../src/app/reports/reports-menu.component.ts | 166 ---- .../src/app/reports/reports.component.ts | 34 +- .../app/ui/app-settings-modal.component.ts | 494 ------------ .../tests/asset-manager/duration.pipe.spec.ts | 30 - .../building-list.component.spec.ts | 1 - .../day-view/dayview-topbar.component.spec.ts | 66 -- .../parking-requests-list.component.spec.ts | 119 --- ...rking-requests-week-view.component.spec.ts | 182 ----- ...ng-special-request-modal.component.spec.ts | 73 -- .../reports/reports-menu.component.spec.ts | 67 -- .../tests/reports/reports.component.spec.ts | 72 -- .../ui/app-settings-modal.component.spec.ts | 294 ------- apps/control/src/app/status-bar.component.ts | 8 +- apps/control/src/app/ui/duration.pipe.ts | 21 - .../src/tests/status-bar.component.spec.ts | 4 +- .../src/tests/ui/duration.pipe.spec.ts | 20 - .../src/app/explore-level-select.component.ts | 49 -- .../explore-level-select.component.spec.ts | 45 -- .../src/app/checkin/checkin.component.ts | 1 - .../app/components/topbar-header.component.ts | 88 --- .../src/tests/welcome.component.spec.ts | 2 - apps/workplace/USER_STORIES.md | 11 - .../src/app/book/flow-success.component.ts | 63 -- .../parking-request-confirm.component.ts | 230 ------ .../components/event-timeline.component.ts | 721 ------------------ .../user-availability.modal.component.ts | 228 ------ .../components/vertical-timeline.component.ts | 429 ----------- .../view-attendees-modal.component.ts | 90 --- .../view-catering-modal.component.ts | 101 --- .../app/landing-new/chat-view.component.ts | 126 --- .../app/landing-new/landing-new.component.ts | 2 - .../landing-virtual-concierge.component.ts | 244 ------ .../schedule-mobile-calendar.component.ts | 170 ----- .../src/environments/settings.schema.json | 5 - apps/workplace/src/environments/settings.ts | 5 - .../tests/book/flow-success.component.spec.ts | 22 - .../parking-request-confirm.component.spec.ts | 155 ---- .../event-timeline.component.spec.ts | 129 ---- .../user-availability.modal.component.spec.ts | 121 --- .../vertical-timeline.component.spec.ts | 88 --- .../view-attendees-modal.component.spec.ts | 42 - .../view-catering-modal.component.spec.ts | 50 -- .../tests/landing/chat-view.component.spec.ts | 55 -- ...anding-virtual-concierge.component.spec.ts | 82 -- docs/settings/concierge.md | 1 - docs/settings/workplace.md | 1 - e2e/support/visitor/your-bookings.page.ts | 3 +- 56 files changed, 11 insertions(+), 6345 deletions(-) delete mode 100644 apps/concierge/src/app/asset-manager/duration.pipe.ts delete mode 100644 apps/concierge/src/app/day-view/dayview-topbar.component.ts delete mode 100644 apps/concierge/src/app/parking/parking-requests-list.component.ts delete mode 100644 apps/concierge/src/app/parking/parking-requests-week-view.component.ts delete mode 100644 apps/concierge/src/app/parking/parking-special-request-modal.component.ts delete mode 100644 apps/concierge/src/app/reports/reports-menu.component.ts delete mode 100644 apps/concierge/src/app/ui/app-settings-modal.component.ts delete mode 100644 apps/concierge/src/tests/asset-manager/duration.pipe.spec.ts delete mode 100644 apps/concierge/src/tests/day-view/dayview-topbar.component.spec.ts delete mode 100644 apps/concierge/src/tests/parking/parking-requests-list.component.spec.ts delete mode 100644 apps/concierge/src/tests/parking/parking-requests-week-view.component.spec.ts delete mode 100644 apps/concierge/src/tests/parking/parking-special-request-modal.component.spec.ts delete mode 100644 apps/concierge/src/tests/reports/reports-menu.component.spec.ts delete mode 100644 apps/concierge/src/tests/reports/reports.component.spec.ts delete mode 100644 apps/concierge/src/tests/ui/app-settings-modal.component.spec.ts delete mode 100644 apps/control/src/app/ui/duration.pipe.ts delete mode 100644 apps/control/src/tests/ui/duration.pipe.spec.ts delete mode 100644 apps/map-kiosk/src/app/explore-level-select.component.ts delete mode 100644 apps/map-kiosk/src/tests/explore-level-select.component.spec.ts delete mode 100644 apps/visitor-kiosk/src/app/components/topbar-header.component.ts delete mode 100644 apps/workplace/src/app/book/flow-success.component.ts delete mode 100644 apps/workplace/src/app/book/parking-request-flow/parking-request-confirm.component.ts delete mode 100644 apps/workplace/src/app/components/event-timeline.component.ts delete mode 100644 apps/workplace/src/app/components/user-availability.modal.component.ts delete mode 100644 apps/workplace/src/app/components/vertical-timeline.component.ts delete mode 100644 apps/workplace/src/app/components/view-attendees-modal.component.ts delete mode 100644 apps/workplace/src/app/components/view-catering-modal.component.ts delete mode 100644 apps/workplace/src/app/landing-new/chat-view.component.ts delete mode 100644 apps/workplace/src/app/landing-new/landing-virtual-concierge.component.ts delete mode 100644 apps/workplace/src/app/schedule/schedule-mobile-calendar.component.ts delete mode 100644 apps/workplace/src/tests/book/flow-success.component.spec.ts delete mode 100644 apps/workplace/src/tests/book/parking-request-flow/parking-request-confirm.component.spec.ts delete mode 100644 apps/workplace/src/tests/components/event-timeline.component.spec.ts delete mode 100644 apps/workplace/src/tests/components/user-availability.modal.component.spec.ts delete mode 100644 apps/workplace/src/tests/components/vertical-timeline.component.spec.ts delete mode 100644 apps/workplace/src/tests/components/view-attendees-modal.component.spec.ts delete mode 100644 apps/workplace/src/tests/components/view-catering-modal.component.spec.ts delete mode 100644 apps/workplace/src/tests/landing/chat-view.component.spec.ts delete mode 100644 apps/workplace/src/tests/landing/landing-virtual-concierge.component.spec.ts diff --git a/apps/concierge/e2e/parking.spec.ts b/apps/concierge/e2e/parking.spec.ts index 8c4239d05f5..ad73ace9f38 100644 --- a/apps/concierge/e2e/parking.spec.ts +++ b/apps/concierge/e2e/parking.spec.ts @@ -18,8 +18,6 @@ test.describe('Parking Bookings Page', () => { await gotoMockRoute(page, '/#/book/parking/events/requests'); await waitForHost(page, 'app-parking'); await waitForHost(page, 'parking-bookings-list'); - - await expect(page.locator('parking-requests-list')).toHaveCount(0); }); // US-030 / US-035: Map view uses a single selected level. diff --git a/apps/concierge/src/app/asset-manager/duration.pipe.ts b/apps/concierge/src/app/asset-manager/duration.pipe.ts deleted file mode 100644 index 7ecefb475f8..00000000000 --- a/apps/concierge/src/app/asset-manager/duration.pipe.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; -import { formatDuration } from '@placeos/common'; - -@Pipe({ - name: 'duration', -}) -export class DurationFormatPipe implements PipeTransform { - public transform(value: number, short = true): string { - const duration = formatDuration({ - days: Math.floor(value / (24 * 60)), - hours: Math.floor((value / 60) % 24), - minutes: Math.floor(value % 60), - }); - if (!short) return duration; - return duration - .replace(' day', 'd') - .replace(' hour', 'hr') - .replace(' minute', 'min'); - } -} diff --git a/apps/concierge/src/app/building-manager/building-list.component.ts b/apps/concierge/src/app/building-manager/building-list.component.ts index c013b7afe19..24ab0d3dd84 100644 --- a/apps/concierge/src/app/building-manager/building-list.component.ts +++ b/apps/concierge/src/app/building-manager/building-list.component.ts @@ -260,9 +260,6 @@ export class BuildingListComponent { public readonly editBuilding = (building) => this._manager.editBuilding(building); - public readonly editBuildingMetadata = (building) => - this._manager.editBuildingMetadata(building); - public readonly removeBuilding = (building) => this._manager.removeBuilding(building); diff --git a/apps/concierge/src/app/building-manager/building-management.service.ts b/apps/concierge/src/app/building-manager/building-management.service.ts index d2f85b14f66..2ffd2809c7d 100644 --- a/apps/concierge/src/app/building-manager/building-management.service.ts +++ b/apps/concierge/src/app/building-manager/building-management.service.ts @@ -68,17 +68,6 @@ export class BuildingManagementService { }); } - public async editBuildingMetadata(zone: PlaceZone = new PlaceZone()) { - const { AppSettingsModalComponent } = - await import('../ui/app-settings-modal.component'); - const ref = this._dialog.open(AppSettingsModalComponent, { - data: { zone }, - }); - ref.afterClosed().subscribe((data) => { - if (data) setTimeout(() => location.reload(), 300); - }); - } - public async setAutoRelease(building: PlaceZone) { if (!building?.id) return; // Loads on demand, so the zone screens do not download its YAML parser diff --git a/apps/concierge/src/app/day-view/dayview-topbar.component.ts b/apps/concierge/src/app/day-view/dayview-topbar.component.ts deleted file mode 100644 index d8147db4daf..00000000000 --- a/apps/concierge/src/app/day-view/dayview-topbar.component.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { Component, inject, OnInit, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatRippleModule } from '@angular/material/core'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { ActivatedRoute, Router } from '@angular/router'; - -import { - AsyncHandler, - Identity, - OrganisationService, - SettingsService, -} from '@placeos/common'; -import { - IconComponent, - SettingsToggleComponent, - TranslatePipe, -} from '@placeos/components'; -import { DateOptionsComponent } from '@placeos/form-fields'; -import { BookingUIOptions, EventsStateService } from './events-state.service'; - -@Component({ - selector: 'dayview-topbar', - template: ` -
- - - - @for (level of levels(); track level) { - - {{ level.display_name || level.name }} - - } - - - - - Legend - @for (type of types; track type) { - - {{ type.name }} - - } - - - @if (allow_setup_breakdown) { - Setup / Breakdown - } -
- - -
- `, - styles: [ - ` - button { - min-width: 0; - padding: 0 0.85rem; - } - - mat-form-field { - height: 3.25em; - width: 8em; - margin-left: 1em; - } - - mat-slide-toggle div { - width: 5.5em; - white-space: initial; - line-height: 1.2em; - } - `, - ], - imports: [ - IconComponent, - SettingsToggleComponent, - DateOptionsComponent, - TranslatePipe, - MatFormFieldModule, - MatSelectModule, - MatSlideToggleModule, - MatRippleModule, - FormsModule, - ], -}) -export class DayviewTopbarComponent extends AsyncHandler implements OnInit { - private _state = inject(EventsStateService); - private _org = inject(OrganisationService); - private _route = inject(ActivatedRoute); - private _router = inject(Router); - private _settings = inject(SettingsService); - - /** List of selected levels */ - public readonly zones = signal([]); - - public readonly types: Identity[] = [ - { id: 'internal', name: 'Internal' }, - { id: 'external', name: 'External' }, - { id: 'cancelled', name: 'Cancelled' }, - ]; - /** List of selected types */ - public readonly type_list = signal(this.types.map((i) => `${i.id}`)); - /** List of levels for the active building */ - public readonly levels = this._state.levels; - /** UI display options for bookings */ - public readonly ui_options = this._state.options; - /** Set filtered date */ - public readonly setDate = (d) => this._state.setDate(d); - /** */ - public readonly newBooking = (d?) => this._state.newBooking(d); - /** List of levels for the active building */ - public readonly updateZones = (zones: string[]) => { - const zone_ids = this._clean_zone_ids(zones); - this.zones.set(zone_ids); - this._state.setZones?.(zone_ids); - this._router.navigate([], { - relativeTo: this._route, - queryParams: { - zone_ids: zone_ids.length ? zone_ids.join(',') : null, - }, - queryParamsHandling: 'merge', - }); - }; - /** List of levels for the active building */ - public readonly updateTypes = (types: string[]) => { - this.type_list.set(types); - this._state.setFilters({ - hide_type: this.types.reduce((list, item) => { - !types.includes(`${item.id}`) ? list.push(item) : ''; - return list; - }, []), - }); - }; - - public updateUIOptions(options: BookingUIOptions) { - this._state.setUIOptions(options); - } - - public get allow_setup_breakdown() { - return this._settings.get('app.events.allow_setup_breakdown'); - } - - public async ngOnInit() { - await this._org.waitUntilInitialised(); - this.subscription( - 'route.query', - this._route.queryParamMap.subscribe((params) => { - if (params.has('zone_ids')) { - const zones = this._clean_zone_ids( - params.get('zone_ids').split(','), - ); - if (zones.length) { - this.zones.set(zones); - this._state.setZones?.(zones); - const level = this._org.levelWithID(zones); - if (!level) return; - this._org.building = this._org.buildings.find( - (bld) => bld.id === level.parent_id, - ); - } - } - }), - ); - this.updateTypes(this.type_list()); - } - - private _clean_zone_ids(zones: string[] = []) { - return (zones || []).filter((zone_id) => !!zone_id); - } -} diff --git a/apps/concierge/src/app/parking/parking-requests-list.component.ts b/apps/concierge/src/app/parking/parking-requests-list.component.ts deleted file mode 100644 index 9bda8be9bd1..00000000000 --- a/apps/concierge/src/app/parking/parking-requests-list.component.ts +++ /dev/null @@ -1,581 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, OnInit, computed, inject, signal } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { MatDialog } from '@angular/material/dialog'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { - AsyncHandler, - Booking, - SettingsService, - settingSignal, -} from '@placeos/common'; -import { - IconComponent, - SimpleTableComponent, - TranslatePipe, -} from '@placeos/components'; -import { BookingApprovalBarComponent } from '../ui/booking-approval-bar.component'; -import { bookingRowKey, selectedBookings } from '../ui/bulk-booking-actions'; -import { ParkingRequestsWeekViewComponent } from './parking-requests-week-view.component'; -import { ParkingSpecialRequestModalComponent } from './parking-special-request-modal.component'; -import { - ParkingRequestFilter, - ParkingStateService, -} from './parking-state.service'; -import { - isParkingAllDayBooking, - parkingRequestStatusLabel, - parkingRequestStatusTone, -} from './parking.utilities'; - -@Component({ - selector: 'parking-requests-list', - template: ` -
-
- @if (last_updated()) { - - {{ - 'COMMON.LAST_UPDATED' - | translate - : { - time: - (last_updated() - | date: time_format : timezone), - } - }} - - } - -
- @if (period() === 'week') { - - } @else { - - - -
- {{ - isAllDayBooking(row) - ? ('COMMON.ALL_DAY' | translate) - : (row.date | date: time_format : timezone) + - ' - ' + - (row.date_end | date: time_format : timezone) - }} -
-
- -
- {{ request_type_label(request_type(row)) | translate }} -
-
- -
- @if (request_submitted_at(row)) { - {{ - request_submitted_at(row) - | date: 'MMM d, ' + time_format - }} - } @else { - {{ 'COMMON.EMPTY' | translate }} - } -
-
- -
-
{{ row.user_name || row.user_email }}
- @if (row.user_name && row.user_email) { -
- {{ row.user_email }} -
- } -
-
- -
-
- {{ row.booked_by_name || row.booked_by_email }} -
- @if (row.booked_by_name && row.booked_by_email) { -
- {{ row.booked_by_email }} -
- } -
-
- - @if (!row?.checked_in && row.checked_out_at) { -
- done -
- } - @if (!row?.checked_in && !row.checked_out_at) { -
- question_mark -
- } - @if (row?.checked_in) { -
- done -
- } -
- -
- {{ row?.extension_data?.plate_number }} - @if (!row?.extension_data?.plate_number) { - - {{ 'COMMON.EMPTY' | translate }} - - } -
-
- -
- -
- - - - -
- -
- - @if (!hide_assign_space) { - - } - -
-
-
- } - @if (!loading().includes('[BOOKINGS]') && has_more_pages()) { - - } -
- - `, - styles: [``], - imports: [ - CommonModule, - MatProgressBarModule, - SimpleTableComponent, - BookingApprovalBarComponent, - TranslatePipe, - MatRippleModule, - IconComponent, - MatMenuModule, - MatTooltipModule, - ParkingRequestsWeekViewComponent, - ], -}) -export class ParkingRequestsListComponent - extends AsyncHandler - implements OnInit -{ - private _state = inject(ParkingStateService); - private _settings = inject(SettingsService); - private _dialog = inject(MatDialog); - - public readonly bookings = this._state.bookings; - public readonly options = this._state.options; - public readonly loading = this._state.loading; - public readonly period = this._state.period; - public readonly has_more_pages = this._state.has_more_pages; - public readonly last_updated = this._state.last_updated; - public readonly loadMore = () => this._state.nextPage(); - public readonly refresh = () => this._state.refresh(); - public readonly load_error = this._state.load_error; - public readonly retryLoad = this.refresh; - public readonly rowKey = bookingRowKey; - public readonly canSelect = (e: Booking) => this.canApproveBooking(e); - /** Whether rows can be selected for bulk approval */ - public readonly bulk_actions = settingSignal('bulk_actions', false); - /** Row keys of the selected bookings */ - public readonly selected = signal([]); - public readonly bulk_busy = signal(false); - - /** Approve or reject the selected bookings */ - public async setApproval(approve: boolean) { - const list = selectedBookings(this.filtered_events(), this.selected()); - this.bulk_busy.set(true); - const done = await this._state - .setBookingsApproval(list, approve) - .finally(() => this.bulk_busy.set(false)); - if (done) this.selected.set([]); - } - - public readonly filtered_events = computed(() => { - const { search, request_filter } = this.options(); - let unallocated = this.bookings().filter((b) => - b.asset_id?.startsWith('unallocated'), - ); - unallocated = this._applyRequestFilter(unallocated, request_filter); - const type_index = (booking: Booking) => - this.request_type(booking) === 'special' - ? 2 - : this.request_type(booking) === 'after_hours' - ? 1 - : 0; - unallocated = [...unallocated].sort((first, second) => { - const type_diff = type_index(first) - type_index(second); - if (type_diff !== 0) return type_diff; - const submitted_first = this.request_submitted_at(first); - const submitted_second = this.request_submitted_at(second); - if (submitted_first !== submitted_second) { - return submitted_second - submitted_first; - } - return second.date - first.date; - }); - return this._state.filterEventSearch(unallocated, search); - }); - - public readonly reject = (e: Booking) => this._state.rejectBooking(e); - public readonly approve = (e: Booking) => this._state.approveBooking(e); - public readonly editReservation = (e: Booking) => - this._state.editReservation(e); - public readonly assignSpace = (e: Booking) => this._state.assignSpace(e); - public readonly viewSpecialNeedsRequest = (booking: Booking) => - this._dialog.open(ParkingSpecialRequestModalComponent, { - data: { booking }, - }); - public readonly request_type = (booking: Booking) => - booking?.extension_data?.request_type || ''; - /** Time that the request was created, in milliseconds */ - public readonly request_submitted_at = (booking: Booking): number => { - const value = booking?.created_at || 0; - // The API returns seconds. A local booking holds milliseconds. - return value < 1_000_000_000_000 ? value * 1000 : value; - }; - - private readonly _request_type_labels: Record = { - standard: 'Standard', - special: 'P2', - after_hours: 'After-hours', - }; - - public readonly request_type_label = (request_type: string) => - this._request_type_labels[request_type] || 'COMMON.EMPTY'; - public readonly canApproveBooking = (e: Booking) => - this._state.canApproveBooking(e); - - private _applyRequestFilter( - list: Booking[], - filter_type: ParkingRequestFilter, - ): Booking[] { - if (filter_type === 'all') return list; - if (filter_type === 'manual') { - return list.filter((b) => this._state.isManualRequest(b)); - } - if (filter_type === 'waitlist') { - if (!this.show_waitlist) return list; - return list.filter((b) => this._state.isWaitlisted(b)); - } - if (filter_type === 'pending') { - return list.filter( - (b) => - b.status === 'tentative' && - (!this.show_waitlist || !this._state.isWaitlisted(b)), - ); - } - return list; - } - - public get time_format() { - return this._settings.time_format; - } - - public get timezone() { - return this._state.timezone; - } - - public get hide_assign_space() { - return !!this._settings.get('app.parking.hide_assign_space'); - } - - public get show_waitlist() { - return this._settings.get('app.parking.show_waitlist') !== false; - } - - /** Status colour tone for tentative requests, empty for any other status */ - public statusTone(booking: Booking): string { - if (booking?.status !== 'tentative') return ''; - return parkingRequestStatusTone(booking, this.show_waitlist); - } - - public requestStatusLabel(booking: Booking): string { - return parkingRequestStatusLabel(booking, this.show_waitlist); - } - - public isAllDayBooking(booking: Booking) { - return isParkingAllDayBooking(booking, this.timezone); - } - - public ngOnInit() { - this._state.refresh(); - } -} diff --git a/apps/concierge/src/app/parking/parking-requests-week-view.component.ts b/apps/concierge/src/app/parking/parking-requests-week-view.component.ts deleted file mode 100644 index afe2f707166..00000000000 --- a/apps/concierge/src/app/parking/parking-requests-week-view.component.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { CommonModule, DatePipe } from '@angular/common'; -import { Component, computed, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { AsyncHandler, Booking, SettingsService } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { addDays, isSameDay, startOfWeek } from 'date-fns'; -import { - ParkingRequestFilter, - ParkingStateService, -} from './parking-state.service'; -import { - isParkingAllDayBooking, - parkingRequestStatusLabel, - parkingRequestStatusTone, -} from './parking.utilities'; - -@Component({ - selector: 'parking-requests-week-view', - template: ` - -
- @for (day of days(); track day) { -
-
-
- {{ day | date: 'EEE, MMM d' : timezone }} -
-
- {{ 'COMMON.TODAY' | translate }} -
-
-
- @for ( - booking of grouped_requests()[day] || []; - track booking.id - ) { -
-
- @if (booking.checked_in) { -
- done -
- } @else if (booking.checked_out_at) { -
- done -
- } @else { -
- question_mark -
- } - - {{ - booking.user_name || - booking.user_email - }} - -
-
- {{ - isAllDayBooking(booking) - ? ('COMMON.ALL_DAY' | translate) - : (booking.date - | date - : time_format - : timezone) + - ' - ' + - (booking.date_end - | date - : time_format - : timezone) - }} -
- @if ( - booking.extension_data?.plate_number; - as plate - ) { -
- {{ plate }} -
- } -
- - - - - - @if (!hide_assign_space) { - - } - -
-
- } - @if (!grouped_requests()[day]?.length) { -
- {{ - 'APP.CONCIERGE.PARKING_REQUESTS_EMPTY' - | translate - }} -
- } -
-
- } -
- `, - styles: [ - ` - :host { - display: flex; - flex-direction: column; - height: 100%; - overflow: auto; - } - `, - ], - imports: [ - CommonModule, - MatProgressBarModule, - MatRippleModule, - MatMenuModule, - MatTooltipModule, - IconComponent, - TranslatePipe, - ], -}) -export class ParkingRequestsWeekViewComponent extends AsyncHandler { - private _state = inject(ParkingStateService); - private _settings = inject(SettingsService); - private _date_pipe = new DatePipe('en'); - - public readonly loading = this._state.loading; - public readonly options = this._state.options; - public readonly bookings = this._state.bookings; - - public readonly days = computed(() => { - const options = this.options(); - const week_start = this._state.week_start; - const start = startOfWeek(options.date, { - weekStartsOn: week_start, - }); - return Array.from({ length: 7 }, (_, i) => addDays(start, i).valueOf()); - }); - - public readonly grouped_requests = computed>( - () => { - const days = this.days(); - const { search, request_filter } = this.options(); - let list = this.bookings().filter((b) => - b.asset_id?.startsWith('unallocated'), - ); - list = this._applyRequestFilter(list, request_filter); - list = this._state.filterEventSearch(list, search); - const grouped: Record = {}; - for (const day of days) { - grouped[day] = list - .filter((b) => { - const booking_date = this._date_pipe.transform( - b.date, - 'yyyy-MM-dd', - this.timezone, - ); - const day_date = this._date_pipe.transform( - day, - 'yyyy-MM-dd', - this.timezone, - ); - return booking_date === day_date; - }) - .sort((a, b) => a.date - b.date); - } - return grouped; - }, - ); - - public readonly reject = (e: Booking) => this._state.rejectBooking(e); - public readonly approve = (e: Booking) => this._state.approveBooking(e); - public readonly editReservation = (e: Booking) => - this._state.editReservation(e); - public readonly assignSpace = (e: Booking) => this._state.assignSpace(e); - public readonly canApproveBooking = (e: Booking) => - this._state.canApproveBooking(e); - - private _applyRequestFilter( - list: Booking[], - filter_type: ParkingRequestFilter, - ): Booking[] { - if (filter_type === 'all') return list; - if (filter_type === 'manual') { - return list.filter((b) => this._state.isManualRequest(b)); - } - if (filter_type === 'waitlist') { - if (!this.show_waitlist) return list; - return list.filter((b) => this._state.isWaitlisted(b)); - } - if (filter_type === 'pending') { - return list.filter( - (b) => - b.status === 'tentative' && - (!this.show_waitlist || !this._state.isWaitlisted(b)), - ); - } - return list; - } - - public get time_format() { - return this._settings.time_format; - } - - public get timezone() { - return this._state.timezone; - } - - public get hide_assign_space() { - return !!this._settings.get('app.parking.hide_assign_space'); - } - - public get show_waitlist() { - return this._settings.get('app.parking.show_waitlist') !== false; - } - - /** Status colour tone for tentative requests, empty for any other status */ - public statusTone(booking: Booking): string { - if (booking?.status !== 'tentative') return ''; - return parkingRequestStatusTone(booking, this.show_waitlist); - } - - public requestStatusLabel(booking: Booking): string { - return parkingRequestStatusLabel(booking, this.show_waitlist); - } - - public isAllDayBooking(booking: Booking) { - return isParkingAllDayBooking(booking, this.timezone); - } - - public isToday(date: number) { - return isSameDay(date, Date.now()); - } -} diff --git a/apps/concierge/src/app/parking/parking-special-request-modal.component.ts b/apps/concierge/src/app/parking/parking-special-request-modal.component.ts deleted file mode 100644 index afafbedc017..00000000000 --- a/apps/concierge/src/app/parking/parking-special-request-modal.component.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, inject } from '@angular/core'; -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { Booking } from '@placeos/common'; -import { - FullscreenModalShellComponent, - IconComponent, - TranslatePipe, -} from '@placeos/components'; - -@Component({ - selector: 'parking-special-request-modal', - template: ` - -
-
-
- {{ 'BOOKINGS.P2_REASON_FOR_REQUEST' | translate }} -
-
- {{ - request_notes || - ('COMMON.EMPTY' | translate) - }} -
-
- -
-
- {{ 'BOOKINGS.P2_ATTACH_SUPPORTING_DOCS' | translate }} -
- @if (attachments.length) { -
- @for (file of attachments; track file.url) { - - attach_file - - {{ file.name }} - - - } -
- } @else { -
- {{ 'COMMON.EMPTY' | translate }} -
- } -
-
-
- `, - styles: [``], - imports: [ - CommonModule, - TranslatePipe, - IconComponent, - FullscreenModalShellComponent, - ], -}) -export class ParkingSpecialRequestModalComponent { - private _data = inject<{ booking: Booking }>(MAT_DIALOG_DATA); - - public readonly request_notes: string = - `${this._data.booking?.extension_data?.notes || this._data.booking?.description || ''}`.trim(); - - public readonly attachments: { name: string; url: string }[] = (() => { - const urls: string[] = ( - this._data.booking?.extension_data?.attachments || [] - ).filter((url) => !!url); - return urls.map((url) => ({ - url, - name: this._fileNameFromUrl(url), - })); - })(); - - private _fileNameFromUrl(url: string): string { - const last_part = `${url || ''}`.split('/').pop() || ''; - return decodeURIComponent(last_part || 'Uploaded file'); - } -} diff --git a/apps/concierge/src/app/region-manager/region-management.service.ts b/apps/concierge/src/app/region-manager/region-management.service.ts index 73744025ce7..666aeee2fa1 100644 --- a/apps/concierge/src/app/region-manager/region-management.service.ts +++ b/apps/concierge/src/app/region-manager/region-management.service.ts @@ -59,17 +59,6 @@ export class RegionManagementService { }); } - public async editRegionMetadata(region: PlaceZone = new PlaceZone()) { - const { AppSettingsModalComponent } = - await import('../ui/app-settings-modal.component'); - const ref = this._dialog.open(AppSettingsModalComponent, { - data: { zone: region }, - }); - ref.afterClosed().subscribe((data) => { - if (data) setTimeout(() => location.reload(), 300); - }); - } - public async removeRegion(region: Region) { const removed = await confirmAction( this._dialog, diff --git a/apps/concierge/src/app/reports/reports-menu.component.ts b/apps/concierge/src/app/reports/reports-menu.component.ts deleted file mode 100644 index 3c7b2d78e6d..00000000000 --- a/apps/concierge/src/app/reports/reports-menu.component.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { RouterModule } from '@angular/router'; -import { SettingsService } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; - -const DEFAULT_FEATURES = [ - 'attendance', - 'desks', - 'spaces', - 'catering', - 'contact-tracing', -]; - -@Component({ - selector: 'reports-menu,[reports-menu]', - template: ` - - `, - styles: [ - ` - :host { - display: flex; - flex-direction: row !important; - align-items: center; - justify-content: center; - background: inherit; - } - - .grid { - display: grid; - 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; - } - `, - ], - imports: [RouterModule, IconComponent, MatRippleModule, TranslatePipe], -}) -export class ReportsMenuComponent { - private _settings = inject(SettingsService); - - public readonly custom_reports = this._settings.signal( - 'app.custom_reports', - [], - true, - ); - public readonly features = this._settings.signal( - 'app.reports.features', - DEFAULT_FEATURES, - true, - ); -} diff --git a/apps/concierge/src/app/reports/reports.component.ts b/apps/concierge/src/app/reports/reports.component.ts index 7c3a0cf13ac..9a3de296e39 100644 --- a/apps/concierge/src/app/reports/reports.component.ts +++ b/apps/concierge/src/app/reports/reports.component.ts @@ -1,8 +1,6 @@ -import { Component, computed, inject, signal } from '@angular/core'; -import { Router, RouterModule } from '@angular/router'; -import { AsyncHandler } from '@placeos/common'; +import { Component } from '@angular/core'; +import { RouterModule } from '@angular/router'; import { ApplicationSidebarComponent } from '../ui/app-sidebar.component'; -import { ReportsMenuComponent } from './reports-menu.component'; @Component({ selector: '[app-new-reports]', @@ -11,12 +9,6 @@ import { ReportsMenuComponent } from './reports-menu.component';
- @if (path() === 'reports') { -
- }
`, @@ -38,24 +30,6 @@ import { ReportsMenuComponent } from './reports-menu.component'; } `, ], - imports: [ApplicationSidebarComponent, RouterModule, ReportsMenuComponent], + imports: [ApplicationSidebarComponent, RouterModule], }) -export class ReportsComponent extends AsyncHandler { - private _router = inject(Router); - - private readonly _url = signal(null); - - public readonly path = computed(() => { - this._url(); - const parts = this._router.url.split('/'); - return parts[parts.length - 1]; - }); - - constructor() { - super(); - this.subscription( - 'router', - this._router.events.subscribe((event) => this._url.set(event)), - ); - } -} +export class ReportsComponent {} diff --git a/apps/concierge/src/app/ui/app-settings-modal.component.ts b/apps/concierge/src/app/ui/app-settings-modal.component.ts deleted file mode 100644 index 24cd16dfc94..00000000000 --- a/apps/concierge/src/app/ui/app-settings-modal.component.ts +++ /dev/null @@ -1,494 +0,0 @@ -import { Component, inject, OnInit, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatRippleModule } from '@angular/material/core'; -import { - MAT_DIALOG_DATA, - MatDialogModule, - MatDialogRef, -} from '@angular/material/dialog'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { - notifyError, - notifySuccess, - OrganisationService, - SettingsService, -} from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { PlaceZone, showMetadata, updateMetadata } from '@placeos/ts-client'; - -@Component({ - selector: 'app-settings-modal', - template: ` -
-
-

- App Configuration - {{ zone.display_name }} -

-
- @if (!loading()) { -
-
-

General Features

-
- -
-

Features

-
- @for ( - feature of available_features; - track feature - ) { - - } -
-

Landing

-
- @for (feature of landing_features; track feature) { - - } -
- @if (active_features()['spaces']) { - -

- Room Bookings -

-
- @for ( - feature of room_features; - track feature - ) { - - } -
-
- } - @if (active_features()['desks']) { -
-

- Desk Bookings -

-
- @for ( - feature of desk_features; - track feature - ) { - - } -
-
- } -
-
- } @else { -
- -

{{ loading() }}

-
- } - @if (!loading()) { -
- - -
- } - @if (!loading()) { - - } -
- `, - styles: [``], - imports: [ - FormsModule, - MatRippleModule, - MatDialogModule, - MatProgressSpinnerModule, - MatCheckboxModule, - IconComponent, - TranslatePipe, - ], -}) -export class AppSettingsModalComponent implements OnInit { - private _data = inject<{ - zone: PlaceZone; - }>(MAT_DIALOG_DATA); - private _dialog_ref = - inject>(MatDialogRef); - private _org = inject(OrganisationService); - private _settings = inject(SettingsService); - - public readonly zone = this._data.zone; - public readonly workplace_key = - this._settings.get('app.workplace_metadata_key') || 'workplace_app'; - public readonly available_features = [ - 'spaces', - 'desks', - 'explore', - 'parking', - 'help', - 'schedule', - 'wfh', - ]; - public readonly landing_features = [ - 'show_quick_links', - 'hide_availability', - 'hide_colleagues', - 'hide_landing_sidebar', - 'hide_rooms', - 'hide_spaces', - ]; - public readonly room_features = [ - 'allow_all_day', - 'booking_unavailable', - 'can_book_for_others', - 'has_assets', - 'has_catering', - 'allow_externals', - 'hide_notes', - 'allow_recurrence', - ]; - public readonly desk_features = ['allow_all_day']; - public readonly loading = signal(''); - /** True when stored settings failed to load. Blocks saving. */ - public readonly load_failed = signal(false); - public combined_settings: Record = {}; - public readonly active_features = signal>({}); - - public readonly feature_descriptions = { - use_24_hour_time: 'Use 24 Hour Time', - spaces: 'Book Rooms', - desks: 'Book Desks', - explore: 'Explore Maps', - parking: 'Book Parking', - help: 'Help Desk', - schedule: 'Your Bookings', - wfh: 'Work from Home', - - //Landing Features - show_quick_links: 'Show Quick Links', - hide_availability: 'Hide Availability Section', - hide_colleagues: 'Hide Colleagues Listing', - hide_landing_sidebar: 'Hide Sidebar', - hide_rooms: 'Hide Rooms Section', - hide_spaces: 'Hide Spaces Section', - - // Room Features - allow_all_day: 'All Day Bookings', - booking_unavailable: 'No event calendar', - can_book_for_others: 'Can book for others', - has_assets: 'Asset Booking', - has_catering: 'Event Catering', - allow_externals: 'Allow external attendees', - hide_notes: 'Hide notes field', - allow_recurrence: 'Allow recurring bookings', - }; - - public readonly model = signal({ - use_24_hour_time: false, - features: [] as string[], - general: { - show_quick_links: false, - hide_availability: false, - hide_colleagues: false, - hide_landing_sidebar: false, - hide_rooms: false, - hide_spaces: false, - } as Record, - events: { - allow_all_day: false, - booking_unavailable: false, - can_book_for_others: false, - has_assets: false, - has_catering: false, - allow_externals: false, - hide_notes: false, - allow_recurrence: false, - } as Record, - desks: { - allow_all_day: false, - has_assets: false, - } as Record, - bookings: { - allow_all_day: false, - } as Record, - }); - - public toggleFeature(feature: string) { - this.active_features.update((f) => ({ - ...f, - [feature]: !f[feature], - })); - } - - public toggleEventFeature(feature: string) { - this.model.update((m) => ({ - ...m, - events: { ...m.events, [feature]: !m.events[feature] }, - })); - } - - public toggleDeskFeature(feature: string) { - this.model.update((m) => ({ - ...m, - desks: { ...m.desks, [feature]: !m.desks[feature] }, - })); - } - - public async ngOnInit() { - this.loading.set('Loading settings...'); - this._dialog_ref.disableClose = true; - const zone_settings: any = await this._loadSettings(this.zone.id); - const parent_settings: any = await this._loadSettings( - this.zone.parent_id, - ); - const org_settings: any = await this._loadSettings( - this._org.organisation.id, - ); - if (this.load_failed()) { - notifyError('Failed to load settings. Saving is disabled.'); - } - const combined_settings = { - ...org_settings, - ...parent_settings, - ...zone_settings, - general: { - ...org_settings.general, - ...parent_settings.general, - ...zone_settings.general, - }, - events: { - ...org_settings.events, - ...parent_settings.events, - ...zone_settings.events, - }, - desks: { - ...org_settings.desks, - ...parent_settings.desks, - ...zone_settings.desks, - }, - bookings: { - ...org_settings.bookings, - ...parent_settings.bookings, - ...zone_settings.bookings, - }, - }; - this.model.update((m) => ({ - ...m, - use_24_hour_time: - combined_settings.use_24_hour_time ?? m.use_24_hour_time, - features: combined_settings.features ?? m.features, - general: { ...m.general, ...(combined_settings.general || {}) }, - events: { ...m.events, ...(combined_settings.events || {}) }, - desks: { ...m.desks, ...(combined_settings.desks || {}) }, - bookings: { ...m.bookings, ...(combined_settings.bookings || {}) }, - })); - const active_features: Record = {}; - for (const key in combined_settings) { - if (typeof combined_settings[key] === 'object') { - for (const sub_key in combined_settings[key]) { - if (typeof combined_settings[key][sub_key] === 'boolean') { - active_features[sub_key] = - combined_settings[key][sub_key]; - } - } - } else if (typeof combined_settings[key] === 'boolean') { - active_features[key] = combined_settings[key]; - } - } - for (const feature of combined_settings.features || []) { - active_features[feature] = true; - } - this.active_features.set(active_features); - this.combined_settings = combined_settings; - this.loading.set(''); - this._dialog_ref.disableClose = false; - } - - /** - * Settings stored on the zone with `id`. A zone with no stored settings - * gives an empty object. Other failures set `load_failed`, because a save - * would then write the defaults over the stored settings. - */ - private async _loadSettings(id?: string): Promise> { - if (!id) return {}; - try { - const { details } = await showMetadata(id, `${this.workplace_key}`); - return details && !Array.isArray(details) ? details : {}; - } catch (e) { - if ((e as { status?: number })?.status !== 404) { - this.load_failed.set(true); - } - return {}; - } - } - - public async save() { - if (this.load_failed()) return; - const details = this.buildSettings(); - this.loading.set('Saving settings...'); - this._dialog_ref.disableClose = true; - try { - await updateMetadata(this.zone.id, { - name: `${this.workplace_key}`, - details, - description: 'Workplace Application Settings', - }); - } catch (e) { - console.error(e); - this._dialog_ref.disableClose = false; - this.loading.set(''); - notifyError( - `Failed to save settings: ${e.message || e.error || e}`, - ); - throw e; - } - this._dialog_ref.disableClose = false; - this._dialog_ref.close(); - notifySuccess('Successfully saved settings'); - } - - public buildSettings(): Record { - const form_values: any = JSON.parse(JSON.stringify(this.model())); - for (const key in form_values) { - if ( - form_values[key] && - typeof form_values[key] === 'object' && - !Array.isArray(form_values[key]) - ) { - if (key === 'events' || key === 'desks' || key === 'bookings') - continue; - for (const sub_key in form_values[key]) { - if (typeof form_values[key][sub_key] === 'boolean') { - form_values[key][sub_key] = - this.active_features()[sub_key]; - } - } - } else if (typeof form_values[key] === 'boolean') { - form_values[key] = this.active_features()[key]; - } - } - form_values.bookings = form_values.desks; - form_values.features = Object.keys(this.active_features()) - .filter((key) => this.active_features()[key]) - .filter((key) => this.available_features.includes(key)); - return form_values; - } -} diff --git a/apps/concierge/src/tests/asset-manager/duration.pipe.spec.ts b/apps/concierge/src/tests/asset-manager/duration.pipe.spec.ts deleted file mode 100644 index 15a31d24531..00000000000 --- a/apps/concierge/src/tests/asset-manager/duration.pipe.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { DurationFormatPipe } from '../../app/asset-manager/duration.pipe'; - -describe('DurationFormatPipe', () => { - let pipe: DurationFormatPipe; - - beforeEach(() => (pipe = new DurationFormatPipe())); - - it('should abbreviate units in short form by default', () => { - const short = pipe.transform(90); - - expect(short).toContain('hr'); - expect(short).toContain('min'); - expect(short).not.toContain('hour'); - expect(short).not.toContain('minute'); - }); - - it('should keep full unit names in long form', () => { - const long = pipe.transform(90, false); - - expect(long).toContain('hour'); - expect(long).toContain('minute'); - }); - - it('should include days when the duration exceeds a full day', () => { - const value = pipe.transform(24 * 60 + 60, false); - - expect(value).toContain('day'); - expect(value).toContain('hour'); - }); -}); diff --git a/apps/concierge/src/tests/building-manager/building-list.component.spec.ts b/apps/concierge/src/tests/building-manager/building-list.component.spec.ts index 2749598bac3..b82c96ecc4e 100644 --- a/apps/concierge/src/tests/building-manager/building-list.component.spec.ts +++ b/apps/concierge/src/tests/building-manager/building-list.component.spec.ts @@ -36,7 +36,6 @@ describe('BuildingListComponent', () => { manager = { filtered_buildings: signal([{ id: 'b1' }]), editBuilding: vi.fn(), - editBuildingMetadata: vi.fn(), removeBuilding: vi.fn(), setAutoRelease: vi.fn(), setInduction: vi.fn(), diff --git a/apps/concierge/src/tests/day-view/dayview-topbar.component.spec.ts b/apps/concierge/src/tests/day-view/dayview-topbar.component.spec.ts deleted file mode 100644 index 5c8b82beb30..00000000000 --- a/apps/concierge/src/tests/day-view/dayview-topbar.component.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { - createRoutingFactory, - SpectatorRouting, -} from '@ngneat/spectator/vitest'; -import { OrganisationService } from '@placeos/common'; -import { IconComponent } from '@placeos/components'; -import { MockComponent, MockProvider } from 'ng-mocks'; - -import { SettingsService } from '@placeos/common'; -import { DateOptionsComponent } from '@placeos/form-fields'; -import { DayviewTopbarComponent } from '../../app/day-view/dayview-topbar.component'; -import { EventsStateService } from '../../app/day-view/events-state.service'; - -describe('DayviewTopbarComponent', () => { - let spectator: SpectatorRouting; - const createComponent = createRoutingFactory({ - component: DayviewTopbarComponent, - providers: [ - MockProvider(OrganisationService, { - initialised: signal(true), - active_levels: signal([]), - levelWithID: vi.fn(), - }), - MockProvider(EventsStateService, { - levels: signal([]), - options: signal({}), - setFilters: vi.fn(), - setDate: vi.fn(), - setZones: vi.fn(), - newBooking: vi.fn(), - }), - MockProvider(SettingsService, { time_format: 'h:mm a' }), - ], - declarations: [ - MockComponent(DateOptionsComponent), - MockComponent(IconComponent), - ], - imports: [ - MatFormFieldModule, - MatSelectModule, - MatSlideToggleModule, - FormsModule, - ], - }); - - beforeEach(() => (spectator = createComponent())); - - it('should handle query parameters', () => { - expect(spectator.component.zones()).toEqual([]); - spectator.setRouteQueryParam('zone_ids', 'zone-1234,zone-2345'); - spectator.detectChanges(); - expect(spectator.component.zones()).toEqual(['zone-1234', 'zone-2345']); - }); - - it('should allow user to make new bookings', () => { - spectator.click('button[new]'); - expect( - spectator.inject(EventsStateService).newBooking, - ).toHaveBeenCalled(); - }); -}); diff --git a/apps/concierge/src/tests/parking/parking-requests-list.component.spec.ts b/apps/concierge/src/tests/parking/parking-requests-list.component.spec.ts deleted file mode 100644 index 80af2e69389..00000000000 --- a/apps/concierge/src/tests/parking/parking-requests-list.component.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { signal } from '@angular/core'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { Booking, SettingsService } from '@placeos/common'; -import { MockProvider } from 'ng-mocks'; - -import { ParkingRequestsListComponent } from '../../app/parking/parking-requests-list.component'; -import { ParkingStateService } from '../../app/parking/parking-state.service'; - -describe('ParkingRequestsListComponent', () => { - let spectator: Spectator; - let bookings: Booking[] = []; - let show_waitlist = true; - let timezone = 'Australia/Perth'; - let request_filter: 'all' | 'pending' | 'waitlist' = 'all'; - - const createComponent = createComponentFactory({ - component: ParkingRequestsListComponent, - providers: [ - MockProvider(ParkingStateService, { - load_error: signal(false), - bookings: (() => bookings) as any, - options: (() => ({ - date: Date.now(), - search: '', - zones: [], - period: 'day', - request_filter, - })) as any, - loading: (() => []) as any, - period: (() => 'day') as any, - has_more_pages: (() => false) as any, - last_updated: (() => 0) as any, - nextPage: vi.fn(), - refresh: vi.fn(), - filterEventSearch: vi.fn((list: Booking[]) => list), - rejectBooking: vi.fn(), - approveBooking: vi.fn(), - editReservation: vi.fn(), - assignSpace: vi.fn(), - canApproveBooking: vi.fn(() => true), - get timezone() { - return timezone; - }, - week_start: 1, - }), - MockProvider(SettingsService as any, { - get: vi.fn((name: string) => - name === 'app.parking.show_waitlist' - ? show_waitlist - : false, - ), - signal: vi.fn((_: string, initial: boolean) => signal(initial)), - time_format: 'h:mm a', - }), - ], - }); - - beforeEach(() => { - bookings = []; - show_waitlist = true; - timezone = 'Australia/Perth'; - request_filter = 'all'; - }); - - it('should expose the parking display timezone', () => { - timezone = 'Australia/Perth'; - spectator = createComponent(); - - expect(spectator.component.timezone).toBe('Australia/Perth'); - }); - - it('should read the created time of a request from the booking', () => { - spectator = createComponent(); - const created_at = 1_780_000_000; - - expect( - spectator.component.request_submitted_at( - new Booking({ created_at }), - ), - ).toBe(created_at * 1000); - }); - - it('should hide waitlisted status when waitlist display is disabled', () => { - show_waitlist = false; - spectator = createComponent(); - - expect( - spectator.component.statusTone({ - id: 'request-1', - asset_id: 'unallocated-1', - approved: false, - status: 'tentative', - process_state: 'wait_list', - date: Date.now(), - } as Booking), - ).toBe('warning'); - }); - - it('should keep current week tentative requests in pending results when waitlist display is disabled', () => { - show_waitlist = false; - request_filter = 'pending'; - bookings = [ - { - id: 'request-1', - asset_id: 'unallocated-1', - status: 'tentative', - date: Date.now(), - date_end: Date.now() + 60 * 60 * 1000, - duration: 60, - extension_data: {}, - } as Booking, - ]; - - spectator = createComponent(); - - expect(spectator.component.filtered_events()).toHaveLength(1); - expect(spectator.component.filtered_events()[0].id).toBe('request-1'); - }); -}); diff --git a/apps/concierge/src/tests/parking/parking-requests-week-view.component.spec.ts b/apps/concierge/src/tests/parking/parking-requests-week-view.component.spec.ts deleted file mode 100644 index fb768200775..00000000000 --- a/apps/concierge/src/tests/parking/parking-requests-week-view.component.spec.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { signal } from '@angular/core'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { parkingRequestStatus } from '@placeos/bookings'; -import { Booking, SettingsService } from '@placeos/common'; -import { MockProvider } from 'ng-mocks'; - -import { ParkingRequestsWeekViewComponent } from '../../app/parking/parking-requests-week-view.component'; -import { ParkingStateService } from '../../app/parking/parking-state.service'; - -describe('ParkingRequestsWeekViewComponent', () => { - let spectator: Spectator; - let bookings: Booking[] = []; - let show_waitlist = true; - const options = signal({ - date: Date.now(), - search: '', - zones: [], - period: 'week', - request_filter: 'all', - }); - - const createComponent = createComponentFactory({ - component: ParkingRequestsWeekViewComponent, - shallow: true, - providers: [ - MockProvider(ParkingStateService, { - loading: signal([]), - options: options, - bookings: (() => bookings) as any, - week_start: 1, - startPolling: vi.fn(() => () => null), - filterEventSearch: vi.fn((list: Booking[]) => list), - rejectBooking: vi.fn(), - approveBooking: vi.fn(), - editReservation: vi.fn(), - assignSpace: vi.fn(), - canApproveBooking: vi.fn(() => true), - isWaitlisted: vi.fn( - (b: Booking) => - b.status === 'tentative' && - parkingRequestStatus(b) === 'waitlist', - ), - isManualRequest: vi.fn( - (b: Booking) => - b.status === 'tentative' && - parkingRequestStatus(b) === 'approval_required', - ), - timezone: 'Australia/Perth', - } as any), - MockProvider(SettingsService as any, { - get: vi.fn((name: string) => - name === 'app.parking.show_waitlist' - ? show_waitlist - : false, - ), - time_format: 'h:mm a', - }), - ], - }); - - beforeEach(() => { - bookings = []; - show_waitlist = true; - options.set({ - date: Date.now(), - search: '', - zones: [], - period: 'week', - request_filter: 'all', - }); - }); - - it('should only include unallocated request bookings', () => { - const today = Date.now(); - bookings = [ - { - id: 'request-1', - asset_id: 'unallocated-1', - status: 'tentative', - date: today, - } as Booking, - { - id: 'booking-1', - asset_id: 'bay-1', - status: 'approved', - date: today, - } as Booking, - ]; - - spectator = createComponent(); - - const grouped = spectator.component.grouped_requests(); - const all_ids = Object.values(grouped) - .flat() - .map((b) => b.id); - expect(all_ids).toContain('request-1'); - expect(all_ids).not.toContain('booking-1'); - }); - - it('should keep only unapproved requests under the waitlist filter', () => { - const today = Date.now(); - options.set({ ...options(), request_filter: 'waitlist' }); - bookings = [ - { - id: 'waitlisted', - asset_id: 'unallocated-1', - approved: false, - status: 'tentative', - process_state: 'wait_list', - date: today, - } as Booking, - { - id: 'new-request', - asset_id: 'unallocated-2', - status: 'tentative', - date: today, - } as Booking, - ]; - - spectator = createComponent(); - - const all_ids = Object.values(spectator.component.grouped_requests()) - .flat() - .map((b) => b.id); - expect(all_ids).toContain('waitlisted'); - expect(all_ids).not.toContain('new-request'); - }); - - it('should surface requests without a process state under the pending filter', () => { - const today = Date.now(); - options.set({ ...options(), request_filter: 'pending', date: today }); - bookings = [ - { - id: 'new-request', - asset_id: 'unallocated-2', - status: 'tentative', - date: today, - } as Booking, - ]; - - spectator = createComponent(); - - const all_ids = Object.values(spectator.component.grouped_requests()) - .flat() - .map((b) => b.id); - expect(all_ids).toContain('new-request'); - }); - - it('should style requests by their request status', () => { - spectator = createComponent(); - const waitlisted = { - approved: false, - status: 'tentative', - process_state: 'wait_list', - date: Date.now(), - } as Booking; - const manual = { - approved: false, - status: 'tentative', - extension_data: { requires_manual_approval: true }, - date: Date.now(), - } as unknown as Booking; - const pending = { status: 'tentative', date: Date.now() } as Booking; - - expect(spectator.component.statusTone(waitlisted)).toBe('info'); - expect(spectator.component.statusTone(manual)).toBe('approval'); - expect(spectator.component.statusTone(pending)).toBe('warning'); - }); - - it('should hide waitlisted styling when the waitlist display is disabled', () => { - show_waitlist = false; - spectator = createComponent(); - const booking = { - approved: false, - status: 'tentative', - process_state: 'wait_list', - date: Date.now(), - } as Booking; - - expect(spectator.component.statusTone(booking)).toBe('warning'); - }); -}); diff --git a/apps/concierge/src/tests/parking/parking-special-request-modal.component.spec.ts b/apps/concierge/src/tests/parking/parking-special-request-modal.component.spec.ts deleted file mode 100644 index 584f95fc8a4..00000000000 --- a/apps/concierge/src/tests/parking/parking-special-request-modal.component.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { Booking } from '@placeos/common'; - -import { ParkingSpecialRequestModalComponent } from '../../app/parking/parking-special-request-modal.component'; - -describe('ParkingSpecialRequestModalComponent', () => { - let spectator: Spectator; - let booking: Partial; - - const createComponent = createComponentFactory({ - component: ParkingSpecialRequestModalComponent, - shallow: true, - providers: [ - { - provide: MAT_DIALOG_DATA, - useFactory: () => ({ booking }), - }, - ], - }); - - it('should read the reason from the booking extension notes', () => { - booking = { - extension_data: { notes: ' Needs wheelchair access ' }, - description: 'ignored', - }; - - spectator = createComponent(); - - expect(spectator.component.request_notes).toBe( - 'Needs wheelchair access', - ); - }); - - it('should fall back to the booking description when no notes exist', () => { - booking = { - extension_data: {}, - description: 'Fallback reason', - }; - - spectator = createComponent(); - - expect(spectator.component.request_notes).toBe('Fallback reason'); - }); - - it('should map attachment urls into named links and drop empty entries', () => { - booking = { - extension_data: { - attachments: [ - 'https://files.example.com/docs/Doctor%20Note.pdf', - '', - ], - }, - }; - - spectator = createComponent(); - - expect(spectator.component.attachments).toEqual([ - { - url: 'https://files.example.com/docs/Doctor%20Note.pdf', - name: 'Doctor Note.pdf', - }, - ]); - }); - - it('should expose an empty attachment list when none are provided', () => { - booking = { extension_data: {} }; - - spectator = createComponent(); - - expect(spectator.component.attachments).toEqual([]); - }); -}); diff --git a/apps/concierge/src/tests/reports/reports-menu.component.spec.ts b/apps/concierge/src/tests/reports/reports-menu.component.spec.ts deleted file mode 100644 index b39ada395fa..00000000000 --- a/apps/concierge/src/tests/reports/reports-menu.component.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { - createRoutingFactory, - SpectatorRouting, -} from '@ngneat/spectator/vitest'; -import { signal, WritableSignal } from '@angular/core'; -import { MockComponent, MockPipe } from 'ng-mocks'; - -import { MatRippleModule } from '@angular/material/core'; -import { SettingsService } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { ReportsMenuComponent } from 'apps/concierge/src/app/reports/reports-menu.component'; - -describe('ReportsMenuComponent', () => { - let spectator: SpectatorRouting; - let features: WritableSignal; - let custom_reports: WritableSignal; - const createComponent = createRoutingFactory({ - component: ReportsMenuComponent, - declarations: [MockComponent(IconComponent), MockPipe(TranslatePipe)], - providers: [ - { - provide: SettingsService, - useValue: { - signal: (key: string) => - key === 'app.reports.features' - ? features - : custom_reports, - }, - }, - ], - imports: [MatRippleModule], - stubsEnabled: true, - }); - - beforeEach(() => { - features = signal(['desks', 'spaces']); - custom_reports = signal([]); - spectator = createComponent(); - }); - - function card_titles(): string[] { - return spectator - .queryAll('a h3') - .map((el) => el.textContent.trim()); - } - - it('should only render cards for enabled features', () => { - expect(spectator.queryAll('a').length).toBe(2); - expect(card_titles()).toEqual(['Desks', 'Rooms']); - expect(card_titles()).not.toContain('Catering'); - }); - - it('should render additional feature cards when enabled', () => { - features.set(['desks', 'spaces', 'catering', 'contact-tracing']); - spectator.detectChanges(); - expect(spectator.queryAll('a').length).toBe(4); - expect(card_titles()).toContain('Catering'); - expect(card_titles()).toContain('Contact Tracing'); - }); - - it('should render custom report cards', () => { - custom_reports.set([{ id: 'kpi', icon: 'star', name: 'KPI Report' }]); - spectator.detectChanges(); - expect(spectator.queryAll('a').length).toBe(3); - expect(card_titles()).toContain('KPI Report'); - }); -}); diff --git a/apps/concierge/src/tests/reports/reports.component.spec.ts b/apps/concierge/src/tests/reports/reports.component.spec.ts deleted file mode 100644 index bef1e0703a3..00000000000 --- a/apps/concierge/src/tests/reports/reports.component.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Component } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MockComponent } from 'ng-mocks'; -import { Subject } from 'rxjs'; - -import { Router } from '@angular/router'; -import { ReportsMenuComponent } from 'apps/concierge/src/app/reports/reports-menu.component'; -import { ReportsComponent } from 'apps/concierge/src/app/reports/reports.component'; -import { ApplicationSidebarComponent } from 'apps/concierge/src/app/ui/app-sidebar.component'; - -@Component({ selector: 'router-outlet', template: '' }) -class StubRouterOutlet {} - -describe('ReportsComponent', () => { - let spectator: Spectator; - let router_events: Subject; - let current_url: string; - const createComponent = createComponentFactory({ - component: ReportsComponent, - overrideComponents: [ - [ - ReportsComponent, - { - remove: { imports: [RouterModule] }, - add: { imports: [StubRouterOutlet] }, - }, - ], - ], - declarations: [ - MockComponent(ApplicationSidebarComponent), - MockComponent(ReportsMenuComponent), - ], - providers: [ - { - provide: Router, - useValue: { - get events() { - return router_events; - }, - get url() { - return current_url; - }, - }, - }, - ], - }); - - beforeEach(() => { - router_events = new Subject(); - current_url = '/reports/desks'; - spectator = createComponent(); - }); - - it('should derive the active path from the router url', () => { - expect(spectator.component.path()).toBe('desks'); - }); - - it('should recompute the path when the router emits a navigation event', () => { - current_url = '/reports'; - router_events.next({ id: 1 }); - expect(spectator.component.path()).toBe('reports'); - }); - - it('should show the reports menu only on the base reports path', () => { - expect(spectator.query('[reports-menu]')).toBeFalsy(); - current_url = '/reports'; - router_events.next({ id: 2 }); - spectator.detectChanges(); - expect(spectator.query('[reports-menu]')).toBeTruthy(); - }); -}); diff --git a/apps/concierge/src/tests/ui/app-settings-modal.component.spec.ts b/apps/concierge/src/tests/ui/app-settings-modal.component.spec.ts deleted file mode 100644 index 84967ad5cc8..00000000000 --- a/apps/concierge/src/tests/ui/app-settings-modal.component.spec.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { - OrganisationService, - setNotifyOutlet, - SettingsService, -} from '@placeos/common'; -import { mockComponent } from '@placeos/common/tests'; -import { IconComponent } from '@placeos/components'; -import { MockProvider } from 'ng-mocks'; -import { AppSettingsModalComponent } from '../../app/ui/app-settings-modal.component'; - -import * as ts_client from '@placeos/ts-client'; - -vi.mock('@placeos/ts-client', { spy: true }); - -describe('AppSettingsModalComponent', () => { - let spectator: Spectator; - let notify_open: ReturnType; - - const mock_zone = { - id: 'zone-1', - name: 'Test Zone', - display_name: 'Test Zone Display', - parent_id: 'zone-parent', - }; - - const createComponent = createComponentFactory({ - component: AppSettingsModalComponent, - declarations: [mockComponent(IconComponent)], - providers: [ - MockProvider(MAT_DIALOG_DATA, { zone: mock_zone }), - MockProvider(MatDialogRef, { - close: vi.fn(), - disableClose: false, - }), - MockProvider(SettingsService, { - get: vi.fn(() => 'workplace_app'), - } as any), - MockProvider(OrganisationService, { - organisation: { id: 'org-1' }, - } as any), - ], - imports: [ - NoopAnimationsModule, - FormsModule, - ReactiveFormsModule, - MatCheckboxModule, - MatProgressSpinnerModule, - ], - }); - - beforeEach(() => { - notify_open = vi.fn(() => ({ - onAction: () => ({ subscribe: () => undefined }), - dismiss: () => undefined, - })); - setNotifyOutlet({ open: notify_open } as any, true); - vi.mocked(ts_client.showMetadata).mockResolvedValue({ - details: {}, - } as never); - vi.mocked(ts_client.updateMetadata).mockResolvedValue({} as never); - spectator = createComponent(); - }); - - afterEach(() => { - setNotifyOutlet(null, true); - vi.restoreAllMocks(); - }); - - it('should have a model with general values', () => { - const model = spectator.component.model(); - expect('use_24_hour_time' in model).toBe(true); - expect('features' in model).toBe(true); - }); - - it('should have events model group', () => { - const events = spectator.component.model().events; - expect(events).toBeTruthy(); - expect('allow_all_day' in events).toBe(true); - expect('can_book_for_others' in events).toBe(true); - expect('has_assets' in events).toBe(true); - expect('has_catering' in events).toBe(true); - expect('allow_externals' in events).toBe(true); - }); - - it('should have desks model group', () => { - const desks = spectator.component.model().desks; - expect(desks).toBeTruthy(); - expect('allow_all_day' in desks).toBe(true); - }); - - it('should expose available features list', () => { - expect(spectator.component.available_features).toContain('spaces'); - expect(spectator.component.available_features).toContain('desks'); - expect(spectator.component.available_features).toContain('explore'); - expect(spectator.component.available_features).toContain('parking'); - }); - - it('should expose landing features list', () => { - expect(spectator.component.landing_features).toContain( - 'show_quick_links', - ); - expect(spectator.component.landing_features).toContain( - 'hide_colleagues', - ); - }); - - it('should expose room features list', () => { - expect(spectator.component.room_features).toContain('allow_all_day'); - expect(spectator.component.room_features).toContain( - 'can_book_for_others', - ); - expect(spectator.component.room_features).toContain('has_catering'); - }); - - it('should expose feature descriptions', () => { - expect(spectator.component.feature_descriptions['spaces']).toBe( - 'Book Rooms', - ); - expect(spectator.component.feature_descriptions['desks']).toBe( - 'Book Desks', - ); - expect( - spectator.component.feature_descriptions['use_24_hour_time'], - ).toBe('Use 24 Hour Time'); - }); - - it('should expose the zone from dialog data', () => { - expect(spectator.component.zone).toEqual(mock_zone); - }); - - it('should load settings on init', async () => { - await spectator.component.ngOnInit(); - expect(ts_client.showMetadata).toHaveBeenCalled(); - expect(spectator.component.loading()).toBe(''); - }); - - it('should load metadata from zone, parent, and org', async () => { - await spectator.component.ngOnInit(); - expect(ts_client.showMetadata).toHaveBeenCalledWith( - 'zone-1', - 'workplace_app', - ); - expect(ts_client.showMetadata).toHaveBeenCalledWith( - 'zone-parent', - 'workplace_app', - ); - expect(ts_client.showMetadata).toHaveBeenCalledWith( - 'org-1', - 'workplace_app', - ); - }); - - it('should populate active_features from loaded settings', async () => { - vi.mocked(ts_client.showMetadata).mockResolvedValue({ - details: { - features: ['spaces', 'desks'], - use_24_hour_time: true, - }, - } as never); - await spectator.component.ngOnInit(); - expect(spectator.component.active_features()['spaces']).toBe(true); - expect(spectator.component.active_features()['desks']).toBe(true); - expect(spectator.component.active_features()['use_24_hour_time']).toBe( - true, - ); - }); - - it('should save settings via updateMetadata', async () => { - await spectator.component.ngOnInit(); - await spectator.component.save(); - expect(ts_client.updateMetadata).toHaveBeenCalledWith( - 'zone-1', - expect.objectContaining({ - name: 'workplace_app', - description: 'Workplace Application Settings', - }), - ); - }); - - it('should close dialog after successful save', async () => { - await spectator.component.ngOnInit(); - await spectator.component.save(); - const dialog_ref = spectator.inject(MatDialogRef); - expect(dialog_ref.close).toHaveBeenCalled(); - }); - - it('should show success notification after save', async () => { - await spectator.component.ngOnInit(); - await spectator.component.save(); - expect(notify_open).toHaveBeenCalledWith( - 'Successfully saved settings', - 'OK', - expect.objectContaining({ panelClass: ['success'] }), - ); - }); - - it('should show error notification when save fails', async () => { - vi.mocked(ts_client.updateMetadata).mockRejectedValue({ - message: 'Network error', - } as never); - await spectator.component.ngOnInit(); - await spectator.component.save().catch(() => {}); - expect(notify_open).toHaveBeenCalledWith( - expect.anything(), - 'OK', - expect.objectContaining({ panelClass: ['error'] }), - ); - }); - - it('should block saving when stored settings fail to load', async () => { - vi.mocked(ts_client.showMetadata).mockImplementation(((id: string) => - id === 'zone-1' - ? Promise.reject({ status: 500 }) - : Promise.resolve({ details: {} })) as never); - vi.mocked(ts_client.updateMetadata).mockClear(); - await spectator.component.ngOnInit(); - - await spectator.component.save(); - - expect(spectator.component.load_failed()).toBe(true); - expect(ts_client.updateMetadata).not.toHaveBeenCalled(); - }); - - it('should allow saving when a zone has no stored settings', async () => { - vi.mocked(ts_client.showMetadata).mockRejectedValue({ - status: 404, - } as never); - vi.mocked(ts_client.updateMetadata).mockClear(); - await spectator.component.ngOnInit(); - - await spectator.component.save(); - - expect(spectator.component.load_failed()).toBe(false); - expect(ts_client.updateMetadata).toHaveBeenCalled(); - }); - - it('should build features list from active_features', () => { - spectator.component.active_features.set({ - spaces: true, - desks: false, - explore: true, - parking: false, - help: true, - schedule: false, - wfh: true, - use_24_hour_time: true, - }); - const out = spectator.component.buildSettings(); - const features = out.features; - expect(features).toContain('spaces'); - expect(features).not.toContain('desks'); - expect(features).toContain('explore'); - expect(features).toContain('help'); - expect(features).toContain('wfh'); - }); - - it('should filter features list to only available_features', () => { - spectator.component.active_features.set({ - spaces: true, - use_24_hour_time: true, // not in available_features - allow_all_day: true, // not in available_features - }); - const out = spectator.component.buildSettings(); - const features = out.features; - expect(features).toContain('spaces'); - expect(features).not.toContain('use_24_hour_time'); - expect(features).not.toContain('allow_all_day'); - }); - - it('should copy desks to bookings in buildSettings', () => { - spectator.component.model.update((m) => ({ - ...m, - desks: { ...m.desks, allow_all_day: true }, - })); - const out = spectator.component.buildSettings(); - expect(out.bookings.allow_all_day).toEqual(out.desks.allow_all_day); - }); - - it('should set loading and disableClose during init', async () => { - const dialog_ref = spectator.inject(MatDialogRef); - const init_promise = spectator.component.ngOnInit(); - expect(spectator.component.loading()).toBe('Loading settings...'); - expect(dialog_ref.disableClose).toBe(true); - await init_promise; - expect(spectator.component.loading()).toBe(''); - expect(dialog_ref.disableClose).toBe(false); - }); -}); diff --git a/apps/control/src/app/status-bar.component.ts b/apps/control/src/app/status-bar.component.ts index 1648698dd70..b913dd1f48d 100644 --- a/apps/control/src/app/status-bar.component.ts +++ b/apps/control/src/app/status-bar.component.ts @@ -5,10 +5,10 @@ import { MatSliderModule } from '@angular/material/slider'; import { BindingDirective, IconComponent, + MediaDurationPipe, TranslatePipe, } from '@placeos/components'; import { ControlStateService } from './control-state.service'; -import { DurationPipe } from './ui/duration.pipe'; @Component({ selector: 'control-status-bar', @@ -118,13 +118,13 @@ import { DurationPipe } from './ui/duration.pipe';
-
{{ rec_remaining | duration }}
+
{{ rec_remaining | mediaDuration }}
-
{{ rec_next | duration }}
+
{{ rec_next | mediaDuration }}
} @@ -181,7 +181,7 @@ import { DurationPipe } from './ui/duration.pipe'; FormsModule, MatRippleModule, IconComponent, - DurationPipe, + MediaDurationPipe, TranslatePipe, ], }) diff --git a/apps/control/src/app/ui/duration.pipe.ts b/apps/control/src/app/ui/duration.pipe.ts deleted file mode 100644 index c2ae55577e6..00000000000 --- a/apps/control/src/app/ui/duration.pipe.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; - -export function padLength(value: number | string, length = 2): string { - let str = `${value}`; - while (str.length < length) str = `0${str}`; - return str; -} - -@Pipe({ - name: 'duration', -}) -export class DurationPipe implements PipeTransform { - public transform(length: number): string { - let str = `${padLength(Math.floor(length / 60) % 60)}:${padLength(length % 60)}`; - const hours = Math.floor(length / 60 / 60); - if (hours > 0) { - str = `${padLength(hours)}:${str}`; - } - return str; - } -} diff --git a/apps/control/src/tests/status-bar.component.spec.ts b/apps/control/src/tests/status-bar.component.spec.ts index 2a4d3314927..04b2b1d412a 100644 --- a/apps/control/src/tests/status-bar.component.spec.ts +++ b/apps/control/src/tests/status-bar.component.spec.ts @@ -9,10 +9,10 @@ import { ControlStateService } from '../app/control-state.service'; import { BindingDirective, IconComponent, + MediaDurationPipe, TranslatePipe, } from '@placeos/components'; import { ControlStatusBarComponent } from '../app/status-bar.component'; -import { DurationPipe } from '../app/ui/duration.pipe'; describe('ControlStatusBarComponent', () => { let spectator: Spectator; @@ -21,7 +21,7 @@ describe('ControlStatusBarComponent', () => { declarations: [ mockComponent(IconComponent), mockDirective(BindingDirective), - MockPipe(DurationPipe), + MockPipe(MediaDurationPipe), MockPipe(TranslatePipe, (v) => v), ], providers: [ diff --git a/apps/control/src/tests/ui/duration.pipe.spec.ts b/apps/control/src/tests/ui/duration.pipe.spec.ts deleted file mode 100644 index 00c0f105152..00000000000 --- a/apps/control/src/tests/ui/duration.pipe.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { DurationPipe } from '../../app/ui/duration.pipe'; - -describe('DurationPipe', () => { - let pipe: DurationPipe; - - beforeEach(() => (pipe = new DurationPipe())); - - it('should create pipe', () => { - expect(pipe).toBeTruthy(); - }); - - it('should convert number into a duration value', () => { - expect(pipe.transform(5)).toBe('00:05'); - expect(pipe.transform(30)).toBe('00:30'); - expect(pipe.transform(90)).toBe('01:30'); - expect(pipe.transform(650)).toBe('10:50'); - expect(pipe.transform(4230)).toBe('01:10:30'); - expect(pipe.transform(360000)).toBe('100:00:00'); - }); -}); diff --git a/apps/map-kiosk/src/app/explore-level-select.component.ts b/apps/map-kiosk/src/app/explore-level-select.component.ts deleted file mode 100644 index f9bdeadda4a..00000000000 --- a/apps/map-kiosk/src/app/explore-level-select.component.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { OrganisationService } from '@placeos/common'; -import { ExploreStateService } from '@placeos/explore'; - -@Component({ - selector: 'explore-level-select', - template: ` -
- @for (lvl of levels(); track lvl) { - - } -
- `, - styles: [ - ` - .active { - background: var(--secondary) !important; - color: #fff !important; - } - - button:not(:first-child) { - border-top: 1px solid var(--base-300) !important; - } - `, - ], - imports: [MatRippleModule], -}) -export class ExploreLevelSelectComponent { - private _org = inject(OrganisationService); - private _state = inject(ExploreStateService); - - public readonly levels = this._org.active_levels; - public readonly level = this._state.level; - - public readonly setLevel = (lvl) => this._state.setLevel(lvl.id); -} diff --git a/apps/map-kiosk/src/tests/explore-level-select.component.spec.ts b/apps/map-kiosk/src/tests/explore-level-select.component.spec.ts deleted file mode 100644 index 7325b6d96a4..00000000000 --- a/apps/map-kiosk/src/tests/explore-level-select.component.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { signal } from '@angular/core'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; - -import { OrganisationService } from '@placeos/common'; -import { ExploreStateService } from '@placeos/explore'; - -import { ExploreLevelSelectComponent } from '../app/explore-level-select.component'; - -describe('ExploreLevelSelectComponent', () => { - let spectator: Spectator; - const createComponent = createComponentFactory({ - component: ExploreLevelSelectComponent, - providers: [ - { - provide: ExploreStateService, - useValue: { - level: signal({ id: 'lvl-2' }), - setLevel: vi.fn(), - }, - }, - { - provide: OrganisationService, - useValue: { - initialised: signal(true), - active_levels: signal([ - { id: 'lvl-1', name: 'Level 1' }, - { id: 'lvl-2', name: 'Level 2' }, - ]), - }, - }, - ], - }); - - beforeEach(() => (spectator = createComponent())); - - it('should allow changing levels', () => { - expect('button.active').toExist(); - expect('button.active').toContainText('Level 2'); - expect('button').toExist(); - const state = spectator.inject(ExploreStateService); - expect(state.setLevel).not.toHaveBeenCalled(); - spectator.click('button'); - expect(state.setLevel).toHaveBeenCalledWith('lvl-1'); - }); -}); diff --git a/apps/visitor-kiosk/src/app/checkin/checkin.component.ts b/apps/visitor-kiosk/src/app/checkin/checkin.component.ts index 7ca84bd0fc1..0d9c83185d1 100644 --- a/apps/visitor-kiosk/src/app/checkin/checkin.component.ts +++ b/apps/visitor-kiosk/src/app/checkin/checkin.component.ts @@ -8,7 +8,6 @@ import { minuteClock } from '../minute-clock'; @Component({ selector: '[app-checkin]', template: ` -
diff --git a/apps/visitor-kiosk/src/app/components/topbar-header.component.ts b/apps/visitor-kiosk/src/app/components/topbar-header.component.ts deleted file mode 100644 index e6f97b02950..00000000000 --- a/apps/visitor-kiosk/src/app/components/topbar-header.component.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { - Component, - computed, - DestroyRef, - inject, - signal, -} from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { RouterModule } from '@angular/router'; -import { settingSignal, SettingsService } from '@placeos/common'; -import { AuthenticatedImageDirective } from '@placeos/components'; -import { startOfMinute } from 'date-fns'; - -@Component({ - selector: 'a-topbar-header', - template: ` -
- - Logo - -
- {{ time() | date: 'fullDate' }} -
-
- `, - styles: [ - ` - :host > div { - height: 3.5rem; - } - - a { - border-radius: 0; - } - - img { - max-height: 2.5rem; - max-width: 50vw; - } - `, - ], - imports: [ - CommonModule, - MatRippleModule, - RouterModule, - AuthenticatedImageDirective, - ], -}) -export class TopbarHeaderComponent { - private _settings = inject(SettingsService); - private _destroy_ref = inject(DestroyRef); - - public readonly logo_dark = settingSignal('logo_dark'); - public readonly logo_light = settingSignal('logo_light'); - public readonly logo = computed(() => - this._settings.theme_signal() === 'dark' - ? this.logo_dark() - : this.logo_light(), - ); - - public readonly logo_source = computed( - () => this.logo()?.src || this.logo(), - ); - - public readonly time = signal(startOfMinute(Date.now())); - - public constructor() { - const interval_id = setInterval( - () => this.time.set(startOfMinute(Date.now())), - 60 * 1000, - ); - this._destroy_ref.onDestroy(() => clearInterval(interval_id)); - } -} diff --git a/apps/visitor-kiosk/src/tests/welcome.component.spec.ts b/apps/visitor-kiosk/src/tests/welcome.component.spec.ts index fcffb3f9499..09acd7ae026 100644 --- a/apps/visitor-kiosk/src/tests/welcome.component.spec.ts +++ b/apps/visitor-kiosk/src/tests/welcome.component.spec.ts @@ -18,7 +18,6 @@ import { IconComponent, SanitizePipe, } from '@placeos/components'; -import { TopbarHeaderComponent } from '../app/components/topbar-header.component'; import { WelcomeComponent } from '../app/welcome.component'; describe('WelcomeComponent', () => { @@ -33,7 +32,6 @@ describe('WelcomeComponent', () => { ], imports: [MockModule(MatMenuModule)], declarations: [ - MockComponent(TopbarHeaderComponent), MockComponent(IconComponent), MockPipe(SanitizePipe), MockDirective(AuthenticatedImageDirective), diff --git a/apps/workplace/USER_STORIES.md b/apps/workplace/USER_STORIES.md index 8d1637de8c9..633bea69285 100644 --- a/apps/workplace/USER_STORIES.md +++ b/apps/workplace/USER_STORIES.md @@ -202,17 +202,6 @@ when the relevant feature is enabled and the signed-in user is authorised. - Selecting a day opens the desk booking or schedule flow for that day. - Recurring desk bookings are represented consistently with single-day bookings. -### US-016: Use landing concierge prompts -**As an** employee -**I want to** use suggested prompts on the landing page -**So that** I can ask common workplace questions quickly - -**Acceptance Criteria:** -- The landing virtual concierge surface appears when chat/concierge configuration is enabled. -- Suggested prompts come from configured `virtual_concierge.prompts`. -- Submitting a prompt opens or continues the concierge experience. -- Concierge failures do not prevent the rest of the landing page from rendering. - ### US-017: See organisation messages **As an** employee **I want to** see important workplace announcements diff --git a/apps/workplace/src/app/book/flow-success.component.ts b/apps/workplace/src/app/book/flow-success.component.ts deleted file mode 100644 index 1e840f22248..00000000000 --- a/apps/workplace/src/app/book/flow-success.component.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Component, input } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { RouterModule } from '@angular/router'; -import { currentUser } from '@placeos/common'; -import { IconComponent } from '@placeos/components'; - -@Component({ - selector: 'flow-success', - template: ` -
-
- done -
-
- Thank you, your {{ type() }} booking was successful!
- {{ extra() }} - @if (extra()) { -
- } - An event has been added to {{ title }} calendar -
- -
- `, - styles: [``], - imports: [RouterModule, MatRippleModule, IconComponent], -}) -export class FlowSuccessComponent { - public readonly calendar = input(undefined); - public readonly extra = input(''); - public readonly route = input('spaces'); - public readonly type = input('space'); - - public get is_host(): boolean { - return this.calendar() === currentUser()?.email; - } - - public get title() { - return this.is_host ? 'your' : "the host'"; - } -} diff --git a/apps/workplace/src/app/book/parking-request-flow/parking-request-confirm.component.ts b/apps/workplace/src/app/book/parking-request-flow/parking-request-confirm.component.ts deleted file mode 100644 index f9ca54c2bd5..00000000000 --- a/apps/workplace/src/app/book/parking-request-flow/parking-request-confirm.component.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, inject, model } from '@angular/core'; -import { MatBottomSheetRef } from '@angular/material/bottom-sheet'; -import { MatRippleModule } from '@angular/material/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { BookingFormService } from '@placeos/bookings'; -import { - AsyncHandler, - OrganisationService, - SettingsService, - notifyError, - settingSignal, -} from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; - -const DEFAULT_SPACE_RESTRICTION_OPTIONS = [ - { - id: 'oversized', - name: 'BOOKINGS.PARKING_RESTRICTION_OVERSIZED', - }, -]; - -@Component({ - selector: 'parking-request-confirm', - template: ` -
-

- {{ 'APP.WORKPLACE.PARKING_REQUEST_CONFIRM_TITLE' | translate }} -

-
- @if (loading()) { - - } - @if (show_close()) { - - } -
-
-
- done -
-

- {{ booking.title || '~Untitled~' }} -

-
- calendar_today -
{{ booking.date | date: 'fullDate' }}
-
-
- schedule -
- {{ - booking.all_day - ? ('COMMON.ALL_DAY' | translate) - : (booking.date | date: time_format) + - ' - ' + - (booking.date + booking.duration * 60 * 1000 - | date: time_format + ' (z)') - }} -
-
-
-
-
- done -
-

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

-
- directions_car - - {{ - 'BOOKINGS.PARKING_VEHICLE_' + - (booking.vehicle_type | uppercase) | translate - }} - -
- @if (booking.plate_number) { -
- confirmation_number - {{ booking.plate_number }} -
- } -
- category - - {{ - 'BOOKINGS.PARKING_REQUEST_' + - (booking.request_type | uppercase) | translate - }} - -
- @if (booking.space_restrictions) { -
- warning - - {{ space_restriction_label | translate }} - -
- } - @for (label of extra_space_restriction_labels; track $index) { -
- warning - {{ label | translate }} -
- } - @if ( - booking.recurrence_type && - booking.recurrence_type !== 'none' - ) { -
- repeat - - {{ booking.recurrence_type }} - -
- } - @if (location) { -
- place - {{ location }} -
- } -
-
-
- @if (!loading()) { - - } -
- `, - styles: [ - ` - section > icon { - font-size: 1.5rem; - margin-top: 0.3rem; - } - - h3 { - font-size: 1.25rem; - font-weight: medium; - margin: 0.5rem 0; - } - `, - ], - imports: [ - CommonModule, - MatRippleModule, - IconComponent, - TranslatePipe, - MatProgressSpinnerModule, - ], -}) -export class ParkingRequestConfirmComponent extends AsyncHandler { - private _state = inject(BookingFormService); - private _org = inject(OrganisationService); - private _sheet_ref = inject(MatBottomSheetRef, { optional: true }); - private _settings = inject(SettingsService); - - public readonly show_close = model(false); - - public readonly loading = this._state.loading; - public readonly space_restriction_options = settingSignal< - { id: string; name: string }[] - >('parking.request_space_restrictions', DEFAULT_SPACE_RESTRICTION_OPTIONS); - public readonly extra_space_restriction_options = settingSignal< - { id: string; name: string }[] - >('parking.extra_space_restrictions', []); - - public readonly postForm = async () => { - const r = await this._state.postForm().catch((_) => { - notifyError(`Unable to submit parking request. ${_}`); - }); - if (!r) return; - this.dismiss(true); - }; - public readonly dismiss = (e?) => this._sheet_ref?.dismiss(e); - - public get time_format() { - return this._settings.time_format; - } - - public get booking() { - return this._state.model() as any; - } - - public get location() { - const building = this._org.buildings.find((b) => - this.booking.zones?.includes(b.id), - ); - return building?.display_name || building?.name || ''; - } - - public get space_restriction_label(): string { - const value = this.booking.space_restrictions; - if (!value) return ''; - if (typeof value !== 'string') { - return 'BOOKINGS.PARKING_SPACE_RESTRICTIONS'; - } - return ( - this.space_restriction_options().find((_) => _.id === value) - ?.name || value - ); - } - - public get extra_space_restriction_labels(): string[] { - const value = this.booking.extra_space_restrictions; - if (!Array.isArray(value) || !value.length) return []; - const options = this.extra_space_restriction_options(); - return value.map((id) => options.find((_) => _.id === id)?.name || id); - } -} diff --git a/apps/workplace/src/app/components/event-timeline.component.ts b/apps/workplace/src/app/components/event-timeline.component.ts deleted file mode 100644 index 107bfb97ca0..00000000000 --- a/apps/workplace/src/app/components/event-timeline.component.ts +++ /dev/null @@ -1,721 +0,0 @@ -import { - Component, - ElementRef, - input, - model, - OnChanges, - OnInit, - output, - SimpleChanges, - viewChild, -} from '@angular/core'; -import { AsyncHandler } from '@placeos/common'; -import { - addDays, - addMinutes, - differenceInMinutes, - differenceInSeconds, - format, - isAfter, - isBefore, - isSameMinute, - set, - startOfDay, - startOfMinute, -} from 'date-fns'; - -export interface ITimelineEventGroup { - name?: string; - divider?: boolean; - can_toggle?: boolean; - disabled?: boolean; - required?: boolean; - events?: ITimelineEvent[]; - blocks?: ITimelineBlock[]; - remove?: () => void; -} - -export interface ITimelineEvent { - start: number; - duration: number; - text?: string; -} - -interface ITimelineBlock { - id: string; - display?: string; - short?: string; - hour?: boolean; - divider?: boolean; - unavailable?: boolean; - events?: ITimelineEvent[]; -} - -@Component({ - selector: 'event-timeline', - template: ` -
-
-
-
-
- @for (blk of model.blocks || []; track blk) { -
- @if (blk.hour) { -
- {{ blk.short }} -
- } -
- } -
-
-
-
-
-
-
- @for (blk of model.blocks || []; track blk) { -
-
-
- } - @if (model.today > 0) { -
- } - @if (can_select()) { -
-
-
-
-
- } -
-
- @for (grp of this.model.groups || []; track grp) { -
-
-
{{ grp.name }}
- @if (grp.remove) { -
- close -
- } -
-
- @for (blk of grp.blocks; track blk) { -
- @for (event of blk.events; track event) { -
-
- {{ event.text }} -
-
- } -
- } -
-
- } -
-
- `, - styles: [ - ` - :host { - height: 100%; - width: 100%; - } - - .times { - position: sticky; - top: 0; - left: 0; - overflow: auto; - border: 1px solid #ccc; - } - - .content { - position: relative; - overflow: auto; - border: 1px solid #ccc; - border-top: none; - .row { - border-top: 1px solid #ccc; - &:first-child { - border-top: none; - } - } - .ovrelay .line { - overflow: auto; - } - } - - .row { - position: relative; - display: flex; - align-items: center; - min-width: 100%; - min-height: 2.5em; - z-index: 0; - - &.divider { - background-color: #f0f0f0; - z-index: 2; - } - } - - .overlay { - position: absolute; - height: 100%; - width: 100%; - pointer-events: none; - background: none; - border: none; - z-index: 1; - - .line { - pointer-events: auto; - } - - .mini-block { - pointer-events: auto; - &.unavailable { - background-color: rgba(#000, 0.05); - } - &:first-child { - opacity: 0; - } - } - - .bar { - position: absolute; - top: 0; - left: 0; - width: 0; - height: 100%; - - &.hour { - border-left-style: solid; - } - - &.divider { - border-left: 1px dashed rgba(#000, 0.05); - } - } - } - - .line { - position: relative; - display: flex; - align-items: center; - flex: 1; - min-width: 10em; - height: 100%; - overflow: hidden; - pointer-events: none; - } - - .time { - position: absolute; - left: 0; - bottom: 0.5em; - transform: translateX(-50%); - display: flex; - align-items: center; - white-space: nowrap; - } - - .header { - position: sticky; - display: flex; - align-items: center; - top: 0; - left: 0; - bottom: 0; - width: 10em; - border-right: 1px solid #ccc; - min-height: 2.5em; - .text { - padding: 0.5em; - } - } - - .vertical { - .row { - flex-direction: column; - min-width: 0; - min-height: 100%; - } - - .mini-block { - min-height: 4px; - min-width: 2.5em; - } - - .selected-time { - top: auto; - bottom: auto; - left: 0; - right: 0; - border: none; - border-top: 2px solid var(--secondary); - border-bottom: 2px solid var(--secondary); - pointer-events: auto; - - .knob { - position: absolute; - top: 0; - left: 1em; - transform: translateY(calc(-50% - 1px)); - cursor: ns-resize; - &.end { - top: auto; - bottom: 0; - left: auto; - right: 1em; - transform: translateY(calc(50% + 1px)); - } - } - } - - .curent-time { - top: auto; - bottom: auto; - left: 0; - right: 0; - width: auto; - height: 2px; - background-color: var(--secondary); - } - } - - .mini-block { - position: relative; - min-width: 4px; - min-height: 2.5em; - height: 100%; - flex: 1; - } - - .item { - display: flex; - align-items: center; - justify-content: center; - color: #fff; - position: absolute; - top: 0; - left: 0; - bottom: 0; - background-color: rgba(var(--secondary), 0.54); - border: 1px solid rgba(#fff, 0.87); - } - - .icon { - display: flex; - align-items: center; - justify-content: center; - height: 1.2em; - width: 1.2em; - img { - max-height: 1em; - max-width: 1em; - } - } - - .selected-time { - position: absolute; - top: 0; - bottom: 0; - background-color: rgba(var(--secondary), 0.2); - border-left: 2px solid var(--secondary); - border-right: 2px solid var(--secondary); - pointer-events: auto; - - .inner { - height: 100%; - width: 100%; - cursor: grab; - } - - .knob { - position: absolute; - top: 1em; - left: 0; - height: 12px; - width: 12px; - border-radius: 100%; - background-color: var(--secondary); - transform: translateX(calc(-50% - 1px)); - box-shadow: - 0 1px 3px 0 rgba(0, 0, 0, 0.2), - 0 1px 1px 0 rgba(0, 0, 0, 0.14), - 0 2px 1px -1px rgba(0, 0, 0, 0.12); - cursor: ew-resize; - &.end { - top: auto; - left: auto; - right: 0; - bottom: 1em; - transform: translateX(calc(50% + 1px)); - } - } - } - - .current-time { - position: absolute; - top: 0; - bottom: 0; - width: 2px; - background-color: var(--primary-dark); - } - `, - ], - imports: [], -}) -export class EventTimelineComponent - extends AsyncHandler - implements OnInit, OnChanges -{ - /** Groups and events */ - public readonly groups = input(undefined); - /** Selected date */ - public readonly date = model(Date.now()); - /** Selected duration in min */ - public readonly duration = model(30); - /** Whether time can be changed */ - public readonly can_select = input(true); - public readonly klass = input(undefined); - - /** Output emitter */ - public readonly groupsChange = output(); - - public vertical = false; - public model: { [name: string]: any } = {}; - - public readonly content = viewChild>('overlay'); - - public ngOnInit(): void { - this.model.start = set(this.date(), { hours: 6, minutes: 30 }); - this.model.end = set(this.date(), { hours: 21, minutes: 30 }); - this.generateBlocks(); - this.updateTime(); - this.interval('timer', () => this.updateTime(), 60 * 1000); - const now = Date.now(); - (''); - const tomorrow = addDays(startOfDay(now), 1).valueOf(); - this.timeout( - 'update_date', - () => { - this.updateStartEnd(); - this.interval( - 'update_date', - () => this.updateStartEnd(), - 24 * 60 * 60 * 1000, - ); - }, - Math.floor(differenceInSeconds(tomorrow, now)), - ); - } - - public ngOnChanges(changes: SimpleChanges): void { - if (changes.groups) { - this.processGroups(); - } - if (changes.date || changes.duration) { - this.updateStartEnd(); - } - } - - public updateStartEnd() { - this.model.start = set(this.date(), { hours: 6, minutes: 30 }); - this.model.end = set(this.date(), { hours: 21, minutes: 30 }); - this.updatePeriod(); - this.updateTime(); - } - - public generateBlocks() { - const start = this.model.start; - const end = this.model.end; - const blocks: ITimelineBlock[] = []; - for ( - let time = start; - isBefore(time, end); - time = addMinutes(time, 5) - ) { - const minute = new Date(time).getMinutes(); - blocks.push({ - id: format(time, 'HH:mm'), - display: format(time, 'hh:00 a'), - short: format(time, 'hh a'), - hour: minute === 0, - divider: minute % 30 === 0, - }); - } - this.model.blocks = blocks; - } - - public processGroups() { - this.timeout( - 'process', - () => { - this.model.groups = []; - const date = this.model.start; - const end = this.model.end; - for (const grp of this.groups() || []) { - const blocks: ITimelineBlock[] = []; - for ( - let time = date; - isBefore(time, end); - time = addMinutes(time, 5).valueOf() - ) { - const blk_end = addMinutes(time, 5); - let events = []; - if (grp.events) { - events = grp.events.filter((i) => { - const event_time = set(this.date(), { - hours: Math.floor(i.start), - minutes: Math.floor((i.start * 60) % 60), - }); - return ( - (isAfter(event_time, time) || - isSameMinute(event_time, time)) && - isBefore(event_time, blk_end) - ); - }); - } - blocks.push({ - id: format(time, 'HH:mm'), - events, - }); - } - this.model.groups.push({ ...grp, blocks }); - } - this.checkInUseBlocks(); - }, - 20, - ); - } - - public checkInUseBlocks() { - if (!this.model.blocks) { - return; - } - const blocks: ITimelineBlock[] = this.model.blocks.map((i) => ({ - ...i, - unavailable: false, - })); - for (const grp of this.model.groups) { - for (const event of grp.events || []) { - const start = this.hoursToDate(event.start); - const end = addMinutes(start, event.duration); - for ( - let time = start; - isBefore(time, end); - time = addMinutes(time, 5).valueOf() - ) { - const display = format(time, 'HH:mm'); - const blk = blocks.find((i) => i.id === display); - if (blk) { - blk.unavailable = true; - } - } - } - } - this.model.blocks = blocks; - } - - public hoursToDate(time: number) { - let t = set(startOfDay(Date.now()), { - hours: Math.floor(time), - minutes: Math.floor((time * 60) % 60), - }); - t = set(t, { minutes: Math.floor(t.getMinutes() / 5) * 5 }); - return t.valueOf(); - } - - public timeToDate(timestamp: string) { - const parts = timestamp.split(':'); - let t = set(startOfDay(Date.now()), { - hours: +parts[0], - minutes: +parts[1], - }); - t = set(t, { minutes: Math.floor(t.getMinutes() / 5) * 5 }); - return t.valueOf(); - } - - public updatePeriod() { - const start = startOfMinute(this.date()); - const period = - differenceInMinutes(this.model.end, this.model.start) / 60; - this.model.start_time = - differenceInMinutes(start, this.model.start) / 60 / period; - this.model.duration = this.duration() / 60 / period; - } - - public updateTime() { - const start = startOfMinute(Date.now()); - const period = - differenceInMinutes(this.model.end, this.model.start) / 60; - this.model.today = - differenceInMinutes(start, this.model.start) / 60 / period; - } - - public resetMove() { - this.model.move = ''; - } - - public setMove(move: string) { - this.model.move = move; - } - - public setTime(block: ITimelineBlock) { - if (!block) { - return; - } - const parts = block.id.split(':'); - const time = set(this.date(), { - hours: +parts[0], - minutes: +parts[1], - }); - this.date.set(time.valueOf()); - this.updatePeriod(); - } - - public move(e) { - this.timeout( - 'move', - () => { - const content = this.content(); - if (this.model.move && content) { - const center = { - x: - e.touches && e.touches.length > 0 - ? e.touches[0].clientX || e.clientX - : e.clientX, - y: - e.touches && e.touches.length > 0 - ? e.touches[0].clientY || e.clientY - : e.clientY, - }; - const content_box = - content.nativeElement.getBoundingClientRect(); - const percent_w = - (center.x - content_box.left) / content_box.width; - const percent_h = - (center.y - content_box.top) / content_box.height; - const percent = !this.vertical ? percent_w : percent_h; - - const start_date = new Date(this.model.start); - const end_date = new Date(this.model.end); - const start_time = - start_date.getHours() + start_date.getMinutes() / 60; - const end_time = - end_date.getHours() + end_date.getMinutes() / 60; - const diff_time = end_time - start_time; - const block_size = 15; - const hour = - Math.ceil( - (diff_time * percent + start_time) * - (60 / block_size), - ) / - (60 / block_size); - if (this.model.move === 'end') { - let date = this.date(); - const end = set(this.date(), { - hours: Math.floor(hour), - minutes: Math.floor((hour * 60) % 60), - }); - if (isSameMinute(end, date) || isBefore(end, date)) { - date = addMinutes(end, -this.duration()).valueOf(); - } else { - const duration = Math.floor( - differenceInMinutes(end, date), - ); - this.duration.set( - Math.max(60, duration || block_size), - ); - } - } else if (this.model.move === 'start') { - const date = set(this.date(), { - hours: Math.floor(hour), - minutes: Math.floor((hour * 60) % 60), - }); - this.date.set(date.valueOf()); - } - this.updatePeriod(); - } - }, - 10, - ); - } -} diff --git a/apps/workplace/src/app/components/user-availability.modal.component.ts b/apps/workplace/src/app/components/user-availability.modal.component.ts deleted file mode 100644 index eabc45b3369..00000000000 --- a/apps/workplace/src/app/components/user-availability.modal.component.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { - Component, - computed, - effect, - inject, - OnInit, - output, - signal, -} from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; -import { endOfDay, getUnixTime, isBefore, set, startOfDay } from 'date-fns'; - -import { AsyncHandler, DialogEvent, StaffUser } from '@placeos/common'; -import { - EventTimelineComponent, - ITimelineEventGroup, -} from './event-timeline.component'; - -import { FormsModule } from '@angular/forms'; -import { MatRippleModule } from '@angular/material/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { IconComponent } from '@placeos/components'; -import { queryEvents } from '@placeos/events'; -import { DateFieldComponent } from '@placeos/form-fields'; -import { VerticalTimelineComponent } from './vertical-timeline.component'; - -@Component({ - selector: 'app-user-availability-modal', - template: ` -
-
Attendees Availability
- -
- -
-
- -
- @if (!loading()) { -
- -
-
- -
- } @else { -
- -
- } -
-
- -
- - -
-
- `, - styles: [ - ` - .heading { - flex: 1; - text-align: center; - } - - .not-mobile { - @media screen and (max-width: 639px) { - display: none; - } - } - - .mobile-only { - @media screen and (min-width: 640px) { - display: none; - } - } - - .date { - display: flex; - justify-content: center; - margin-bottom: 1em; - } - - .spinner { - display: flex; - justify-content: center; - width: 100%; - height: 3em; - } - `, - ], - imports: [ - MatDialogModule, - IconComponent, - DateFieldComponent, - VerticalTimelineComponent, - EventTimelineComponent, - MatProgressSpinnerModule, - MatRippleModule, - FormsModule, - ], -}) -export class UserAvailabilityModalComponent - extends AsyncHandler - implements OnInit -{ - private _data = inject(MAT_DIALOG_DATA); - private _loaded = false; - - /** Emitter for user action on the modal */ - public readonly event = output(); - /** List of Users to check availability */ - public readonly users = signal([]); - /** Whether the users events are being loaded */ - public readonly loading = signal(false); - /** Selected date in unix ms */ - public readonly date = signal(new Date().valueOf()); - /** Groups */ - public readonly groups = signal([]); - /** Duration */ - public readonly duration = signal(0); - /** Whether date is in the past */ - public readonly is_past = computed(() => isBefore(this.date(), Date.now())); - - public ngOnInit(): void { - this.users.set(this._data.users); - this.date.set(this._data.date); - this.duration.set(this._data.duration); - this.loadAvailability(); - this._loaded = true; - } - - constructor() { - super(); - effect((onCleanup) => { - this.date(); - if (!this._loaded) return; - const timeout = setTimeout(() => this.loadAvailability(), 500); - onCleanup(() => clearTimeout(timeout)); - }); - } - - /** Load events for all attendees */ - public async loadAvailability() { - this.loading.set(true); - const period_start = getUnixTime(startOfDay(this.date())); - const period_end = getUnixTime(endOfDay(this.date())); - const result: ITimelineEventGroup[] = await Promise.all( - this.users().map(async (user) => { - if (!user.email) return { name: user.name, events: [] }; - return { - name: user.name, - events: await queryEvents({ - period_start, - period_end, - calendars: user.email, - }) - .then((res) => - res.map((i) => ({ - start: new Date(i.date).getHours(), - duration: i.duration, - })), - ) - .catch(() => []), - }; - }), - ); - this.loading.set(false); - this.groups.set(result); - } - - /** Change date keeping hours and minutes */ - public changeDate(new_date: number) { - const current_date = new Date(this.date()); - this.date.set( - set(new_date, { - hours: current_date.getHours(), - minutes: current_date.getMinutes(), - }).valueOf(), - ); - } - - public save() { - if (this.is_past()) { - return; - } - this.event.emit({ - reason: 'done', - metadata: { date: this.date(), duration: this.duration() }, - }); - } -} diff --git a/apps/workplace/src/app/components/vertical-timeline.component.ts b/apps/workplace/src/app/components/vertical-timeline.component.ts deleted file mode 100644 index 24d3094325c..00000000000 --- a/apps/workplace/src/app/components/vertical-timeline.component.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { - Component, - ElementRef, - input, - model, - OnChanges, - OnInit, - output, - signal, - SimpleChanges, - viewChild, -} from '@angular/core'; -import { - addMinutes, - differenceInMinutes, - endOfDay, - format, - isBefore, - isSameMinute, - set, - startOfDay, - startOfMinute, -} from 'date-fns'; - -import { AsyncHandler, formatDuration } from '@placeos/common'; - -import { ITimelineEventGroup } from './event-timeline.component'; - -@Component({ - selector: 'vertical-timeline', - template: ` -
-
-
- @for (item of blocks(); track item) { -
- @if (item.hour) { -
{{ item.display }}
- } -
- } -
-
- @for (item of blocks(); track item) { -
- } -
-
- {{ display }} -
-
-
-
-
-
-
- `, - styles: [ - ` - .timeline { - position: relative; - width: 100%; - padding: 1em; - display: flex; - align-items: center; - justify-content: center; - overflow: auto; - - .content { - display: flex; - margin: auto; - width: 100%; - } - } - - .timeblocks, - .eventblocks { - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - } - - .timeblocks { - width: 3em; - } - - .eventblocks { - position: relative; - flex: 1; - border: 1px solid #ccc; - width: 50%; - overflow: hidden; - .blk { - position: relative; - } - } - - .blk { - position: relative; - min-height: 0.3em; - flex: 1; - width: 100%; - &.shown-block { - border-top: 1px dashed #ccc; - &.hour { - border-top: 1px solid #ccc; - } - &:first-child { - border: none; - } - } - - .text { - position: absolute; - top: 0; - right: 0.5em; - transform: translateY(-50%); - } - - &.unavailable { - background-color: rgba(var(--error), 0.1); - pointer-events: none; - } - - &.disabled { - background-color: rgba(#000, 0.05); - pointer-events: none; - } - } - - .selected-time { - position: absolute; - top: 0; - left: 0; - right: 0; - border: 2px solid var(--secondary); - background-color: var(--secondary); - color: #fff; - z-index: 100; - touch-action: none; - transition: - top 100ms, - height 100ms; - - .inner { - height: 100%; - width: 100%; - padding: 0.25em 1em; - } - - .handle { - position: absolute; - height: 1em; - width: 1em; - border-radius: 100%; - background-color: var(--secondary); - box-shadow: - 0 1px 3px 0 rgba(#000, 0.2), - 0 1px 1px 0 rgba(#000, 0.14), - 0 2px 1px -1px rgba(#000, 0.12); - &.top { - top: 0; - right: 1em; - transform: translateY(-50%); - } - &.bottom { - bottom: 0; - left: 1em; - transform: translateY(50%); - } - } - } - `, - ], - imports: [], -}) -export class VerticalTimelineComponent - extends AsyncHandler - implements OnInit, OnChanges -{ - /** Active date */ - public readonly date = model(undefined); - /** Active duration */ - public readonly duration = model(undefined); - /** Groups and events */ - public readonly groups = input(undefined); - /** Blocks */ - public blocks = signal([]); - /** Element to move */ - public active_move: string; - /** Active start date */ - public active_start = signal(0); - /** Active duration */ - public active_length = signal(0); - /** Offset of time box */ - public offset = { x: 0, y: 0 }; - - /** Output emitter */ - public readonly groupsChange = output(); - - private readonly block = viewChild('block'); - private readonly time = viewChild('time'); - - public ngOnInit(): void { - this.generateBlocks(); - } - - public ngOnChanges(changes: SimpleChanges): void { - if (changes.groups) { - this.checkInUseBlocks(); - } - if (changes.date || changes.duration) { - this.updateStartEnd(); - } - } - - /** Timeline start */ - public get timeline_start() { - return startOfDay(this.date()).valueOf(); - } - - /** Timeline end */ - public get timeline_end() { - return endOfDay(this.date()).valueOf(); - } - - public generateBlocks() { - const blocks = []; - const start = this.timeline_start; - const end = this.timeline_end; - const now = Date.now(); - for ( - let time = start; - isBefore(startOfMinute(time), startOfMinute(end)); - time = addMinutes(time, 5).valueOf() - ) { - const minute = new Date(time).getMinutes(); - blocks.push({ - id: format(time, 'HH:mm'), - display: format(time, 'HH:mm'), - hour: minute === 0, - show: minute % 30 === 0, - disabled: isBefore(time, now), - }); - } - this.blocks.set(blocks); - this.checkInUseBlocks(); - } - - public get display(): string { - const date = this.date(); - const end = addMinutes(this.date(), this.duration()); - const duration = formatDuration({ minutes: this.duration() }); - return `${format(date, 'hh:mm a')} - ${format( - end, - 'hh:mm a', - )} (${duration})`; - } - - public move(e) { - this.timeout( - 'move', - () => { - const block = this.block(); - if (this.active_move && block) { - const center = { - x: - e.touches && e.touches.length > 0 - ? e.touches[0].clientX || e.clientX - : e.clientX, - y: - e.touches && e.touches.length > 0 - ? e.touches[0].clientY || e.clientY - : e.clientY, - }; - const off = - this.active_move === 'bottom' - ? { x: 0, y: 0 } - : this.offset; - const content_box = - block.nativeElement.getBoundingClientRect(); - const percent_h = - (center.y - off.y - content_box.top) / - content_box.height; - const percent = percent_h; - const tl_start = new Date(this.timeline_start); - const start_time = - tl_start.getHours() + tl_start.getMinutes() / 60; - const tl_end = new Date(this.timeline_end); - const end_time = - tl_end.getHours() + tl_end.getMinutes() / 60; - const diff_time = end_time - start_time; - const block_size = 15; - const hour = - Math.ceil( - (diff_time * percent + start_time) * - (60 / block_size), - ) / - (60 / block_size); - if (this.active_move === 'bottom') { - let date = this.date(); - const end = set(this.date(), { - hours: Math.floor(hour), - minutes: Math.floor((hour * 60) % 60), - }); - if (isSameMinute(end, date) || isBefore(end, date)) { - date = addMinutes(end, -this.duration()).valueOf(); - this.date.set(date.valueOf()); - } else { - const duration = Math.floor( - differenceInMinutes(end, date), - ); - this.duration.set( - Math.max(60, duration || block_size), - ); - } - } else if (this.active_move === 'top') { - const date = set(this.date(), { - hours: Math.floor(hour), - minutes: Math.floor((hour * 60) % 60), - }); - this.date.set(date.valueOf()); - } - this.updateStartEnd(); - } - }, - 10, - ); - } - - // public setOffset(e) { - // if (this.time) { - // const box = this.time.nativeElement.getBoundingClientRect(); - // this.offset = { x: e.center.x - box.left, y: e.center.y - box.top }; - // } - // } - - public setMove(move: string) { - this.active_move = move; - } - - public checkInUseBlocks() { - const groups = this.groups(); - if (!this.blocks().length || !groups) { - return; - } - const blocks = this.blocks().map((i) => ({ - ...i, - unavailable: false, - })); - for (const grp of groups) { - for (const event of grp.events || []) { - const start = this.hoursToDate(event.start); - const end = addMinutes(start, event.duration); - for ( - let time = start; - isBefore(time, end); - time = addMinutes(time, 5).valueOf() - ) { - const display = format(time, 'HH:mm'); - const blk = blocks.find((i) => i.id === display); - if (blk) { - blk.unavailable = true; - } - } - } - } - this.blocks.set(blocks); - } - - public hoursToDate(time: number) { - let t = set(startOfDay(Date.now()), { - hours: Math.floor(time), - minutes: Math.floor((time * 60) % 60), - }); - t = set(t, { minutes: Math.floor(t.getMinutes() / 5) * 5 }); - return t.valueOf(); - } - - public updateStartEnd() { - this.updatePeriod(); - } - - public resetMove() { - this.active_move = ''; - } - - public updatePeriod() { - const start = startOfMinute(this.date()); - const period = - differenceInMinutes(this.timeline_end, this.timeline_start) / 60; - this.active_start.set( - differenceInMinutes(start, this.timeline_start) / 60 / period, - ); - this.active_length.set(this.duration() / 60 / period); - } -} diff --git a/apps/workplace/src/app/components/view-attendees-modal.component.ts b/apps/workplace/src/app/components/view-attendees-modal.component.ts deleted file mode 100644 index 5f3912fe152..00000000000 --- a/apps/workplace/src/app/components/view-attendees-modal.component.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Component, OnInit, inject, signal } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; - -import { AsyncHandler, User } from '@placeos/common'; -import { IconComponent } from '@placeos/components'; - -@Component({ - selector: 'schedule-view-attendees', - template: ` -
- -

Attendees

-
- -
- @for (item of attendees(); track item) { -
- - - {{ item.email }} -
- } -
-
- -
- -
-
- `, - styles: [ - ` - .body { - display: flex; - flex-direction: column; - row-gap: 0.5em; - - div { - display: grid; - grid-template-columns: 3em 1fr; - grid-template-rows: 1fr 1fr; - grid-template-areas: 'icon name' 'icon email'; - margin-bottom: 0.5em; - align-items: center; - - icon { - margin: auto; - grid-area: icon; - font-size: 2em; - } - label { - grid-area: name; - } - a { - grid-area: email; - } - } - } - `, - ], - imports: [MatDialogModule, IconComponent], -}) -export class ViewAttendeesModalComponent - extends AsyncHandler - implements OnInit -{ - private _data = inject<{ - attendees: User[]; - }>(MAT_DIALOG_DATA); - - public attendees = signal([]); - - public ngOnInit() { - this.attendees.set(this._data.attendees); - } -} diff --git a/apps/workplace/src/app/components/view-catering-modal.component.ts b/apps/workplace/src/app/components/view-catering-modal.component.ts deleted file mode 100644 index dc9a419974c..00000000000 --- a/apps/workplace/src/app/components/view-catering-modal.component.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Component, OnInit, inject, signal } from '@angular/core'; -import { - MAT_DIALOG_DATA, - MatDialog, - MatDialogModule, -} from '@angular/material/dialog'; -import { Router } from '@angular/router'; - -import { AsyncHandler, CateringItem } from '@placeos/common'; -import { IconComponent } from '@placeos/components'; - -@Component({ - selector: 'view-catering-modal', - template: ` -
- room_service -

Catering

-
-
- Item - Quantity - @for (item of catering(); track item) { - - - } - Total Items - {{ catering_items_total() }} - Note: -
- {{ catering_note() }} -
-
-
- - -
- `, - styles: [ - ` - .body { - display: grid; - grid-template-columns: 1fr 1fr; - row-gap: 0.5em; - - .note-box, - .note-label { - grid-column: 1/-1; - } - - .note-label { - margin-top: 0.5em; - } - - .qty, - .total { - text-align: right; - } - } - `, - ], - imports: [MatDialogModule, IconComponent], -}) -export class ViewCateringModalComponent extends AsyncHandler implements OnInit { - private _data = inject<{ - catering: CateringItem[]; - catering_note: string; - }>(MAT_DIALOG_DATA); - private _router = inject(Router); - private _dialog = inject(MatDialog); - - public catering = signal([]); - public catering_note = signal(''); - public catering_items_total = signal(0); - - public ngOnInit() { - const data = this._data; - - if (data) { - this.catering.set(this._data.catering); - this.catering_note.set(this._data.catering_note); - this.catering_items_total.set( - this.catering().reduce( - (total, item) => item.quantity + total, - 0, - ), - ); - } - } - - /** - * TODO: this makes no sense. catering is not a route to be accessed like this - */ - public edit() { - this._dialog.closeAll(); - this._router.navigate(['/catering/']); - } -} diff --git a/apps/workplace/src/app/landing-new/chat-view.component.ts b/apps/workplace/src/app/landing-new/chat-view.component.ts deleted file mode 100644 index 6ed1214f580..00000000000 --- a/apps/workplace/src/app/landing-new/chat-view.component.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, inject, signal } from '@angular/core'; -import { - MatBottomSheetModule, - MatBottomSheetRef, -} from '@angular/material/bottom-sheet'; -import { MatRippleModule } from '@angular/material/core'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { RouterModule } from '@angular/router'; -import { userSignal } from '@placeos/common'; -import { IconComponent } from '@placeos/components'; - -@Component({ - selector: 'app-chat-view', - template: ` -
- -
-

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

-
-
- -
-

I'm Ben, your virtual concierge

-

Try asking me:

-
- - - -
- - - -

Quick Actions (In App)

- -
-
- `, - styles: [``], - imports: [ - CommonModule, - MatBottomSheetModule, - IconComponent, - MatRippleModule, - MatFormFieldModule, - MatInputModule, - RouterModule, - ], -}) -export class ChatViewComponent { - private _bottom_ref = inject(MatBottomSheetRef); - - public readonly user = userSignal(); - public readonly time = signal(Date.now()); - - public readonly close = () => this._bottom_ref.dismiss(); -} diff --git a/apps/workplace/src/app/landing-new/landing-new.component.ts b/apps/workplace/src/app/landing-new/landing-new.component.ts index 18dde436f04..8af86ce46b6 100644 --- a/apps/workplace/src/app/landing-new/landing-new.component.ts +++ b/apps/workplace/src/app/landing-new/landing-new.component.ts @@ -60,7 +60,6 @@ const SPAN_CLASSES: Record = { class="mx-auto flex w-[80rem] max-w-full flex-col gap-4 p-4" > - @if (!hide_customise()) {
= { FooterMenuComponent, VirtualConciergeButtonComponent, LandingUpcomingBookingComponent, - // LandingVirtualConciergeComponent, LandingAvailableNowComponent, LandingQuickActionsComponent, LandingDeskWeekComponent, diff --git a/apps/workplace/src/app/landing-new/landing-virtual-concierge.component.ts b/apps/workplace/src/app/landing-new/landing-virtual-concierge.component.ts deleted file mode 100644 index 069c39f9ec8..00000000000 --- a/apps/workplace/src/app/landing-new/landing-virtual-concierge.component.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { - Component, - computed, - effect, - ElementRef, - inject, - signal, - viewChild, -} from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { RouterModule } from '@angular/router'; -import { settingSignal, SettingsService, userSignal } from '@placeos/common'; -import { IconComponent } from '@placeos/components'; - -@Component({ - selector: 'landing-virtual-concierge', - template: ` - - @if (!is_fullscreen()) { -
-
-
-
- -
-
-

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

-

- I'm Ben, your virtual concierge -

-
-
- - - - - -
-

Try asking me:

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

Quick actions:

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

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

-

- I'm Ben, your virtual concierge -

-
-
- - - - - -
-

Try asking me:

-
- @for (prompt of prompts(); track prompt) { - - } -
-
-
-
-
- } - `, - styles: [``], - imports: [ - CommonModule, - MatFormFieldModule, - MatInputModule, - MatRippleModule, - RouterModule, - IconComponent, - ], -}) -export class LandingVirtualConciergeComponent { - private readonly _settings = inject(SettingsService); - - public readonly user = userSignal(); - public readonly time = signal(Date.now()); - public readonly is_fullscreen = signal(false); - public readonly features = settingSignal('features', []); - - private readonly _fullscreen_input = - viewChild>('fullscreenInput'); - - public readonly prompts = computed(() => { - const config_prompts = this._settings.signal( - 'virtual_concierge.prompts', - )(); - return config_prompts && Array.isArray(config_prompts) - ? config_prompts - : [ - 'Book a meeting room tomorrow at 2pm for 4 people', - 'Find me an available desk near a window', - 'Reserve parking for Friday morning', - ]; - }); - - constructor() { - effect(() => { - if (this.is_fullscreen() && this._fullscreen_input()) { - setTimeout( - () => this._fullscreen_input()?.nativeElement.focus(), - 100, - ); - } - }); - } - - public openFullscreen() { - this.is_fullscreen.set(true); - } - - public closeFullscreen() { - this.is_fullscreen.set(false); - } -} diff --git a/apps/workplace/src/app/schedule/schedule-mobile-calendar.component.ts b/apps/workplace/src/app/schedule/schedule-mobile-calendar.component.ts deleted file mode 100644 index 905a92f6904..00000000000 --- a/apps/workplace/src/app/schedule/schedule-mobile-calendar.component.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, OnInit, signal } from '@angular/core'; -import { ControlValueAccessor } from '@angular/forms'; -import { MatRipple } from '@angular/material/core'; -import { IconComponent } from '@placeos/components'; -import { - addDays, - addMonths, - isBefore, - isSameDay, - isSameMonth, - startOfDay, - startOfMonth, - startOfWeek, -} from 'date-fns'; -import { provideValueAccessor } from '@placeos/common'; - -@Component({ - selector: 'schedule-mobile-calendar', - template: ` -
-
- -
- - - -
-
-
- @for (day of date_list() | slice: 0 : 7; track day.id) { -
- {{ day.id | date: 'EE' }} -
- } - @for (day of list; track day.id) { - - } -
-
- `, - styles: [``], - providers: [ - provideValueAccessor(() => ScheduleMobileCalendarComponent), - ], - imports: [CommonModule, MatRipple, IconComponent], -}) -export class ScheduleMobileCalendarComponent - implements OnInit, ControlValueAccessor -{ - public active_date = signal(startOfDay(Date.now()).valueOf()); - public offset = 0; - public date_list = signal([]); - public short_list = []; - public show_shortlist = signal(true); - - /** Form control on change handler */ - private _onChange: (_: number) => void; - /** Form control on touch handler */ - private _onTouch: (_: number) => void; - - public ngOnInit() { - this.generateDates(); - } - - public get list() { - return this.show_shortlist() ? this.short_list : this.date_list(); - } - - /** - * Update the form field value - * @param new_value New value to set on the form field - */ - public setValue(new_value: number) { - this.active_date.set(startOfDay(new_value).valueOf()); - this.offset = 0; - if (this._onChange) { - this._onChange(new_value); - } - } - /** - * Update local value when form control value is changed - * @param value The new value for the component - */ - public writeValue(value: number) { - this.active_date.set(startOfDay(value).valueOf()); - this.offset = 0; - this.generateDates(); - } - - public resetMonth() { - this.changeMonth(-this.offset); - } - - public changeMonth(change: number) { - this.offset += change; - this.generateDates(); - } - - /* istanbul ignore next */ - public readonly registerOnChange = (fn) => (this._onChange = fn); - /* istanbul ignore next */ - public readonly registerOnTouched = (fn) => (this._onTouch = fn); - - public generateDates() { - const date = addMonths(this.active_date(), this.offset); - let start = startOfWeek(startOfMonth(date)); - const now = startOfDay(Date.now()); - let list = []; - while (list.length < 7 * 6) { - list.push({ - id: start.valueOf(), - is_past: isBefore(start, now), - is_month: isSameMonth(start, date), - is_today: isSameDay(Date.now(), start), - }); - start = addDays(start, 1); - } - this.date_list.set(list); - start = - this.offset === 0 - ? startOfWeek(date) - : startOfWeek(startOfMonth(date)); - list = []; - while (list.length < 7) { - list.push({ - id: start.valueOf(), - is_past: isBefore(start, now), - is_month: isSameMonth(start, date), - is_today: isSameDay(Date.now(), start), - }); - start = addDays(start, 1); - } - this.short_list = list; - } -} diff --git a/apps/workplace/src/environments/settings.schema.json b/apps/workplace/src/environments/settings.schema.json index 2f2d06a3e0d..d6af5e57bc5 100644 --- a/apps/workplace/src/environments/settings.schema.json +++ b/apps/workplace/src/environments/settings.schema.json @@ -1093,11 +1093,6 @@ "type": "string", "enum": ["left", "right"], "description": "Which side of the screen the virtual concierge sidebar opens on when sidebar display is enabled." - }, - "prompts": { - "type": "array", - "description": "Suggested prompts shown in the virtual concierge UI.", - "items": { "type": "string" } } } } diff --git a/apps/workplace/src/environments/settings.ts b/apps/workplace/src/environments/settings.ts index b400293c9b1..3fee715ea7e 100644 --- a/apps/workplace/src/environments/settings.ts +++ b/apps/workplace/src/environments/settings.ts @@ -229,11 +229,6 @@ const app = { virtual_concierge: { display: 'fullscreen', side: 'left', - prompts: [ - 'Book a meeting room tomorrow at 2pm for 4 people', - 'Find me an available desk near a window', - 'Reserve parking for Friday morning', - ], }, }; /** diff --git a/apps/workplace/src/tests/book/flow-success.component.spec.ts b/apps/workplace/src/tests/book/flow-success.component.spec.ts deleted file mode 100644 index 9542e217247..00000000000 --- a/apps/workplace/src/tests/book/flow-success.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createRoutingFactory } from '@ngneat/spectator/vitest'; -import { IconComponent } from '@placeos/components'; -import { MockComponent } from 'ng-mocks'; - -import { FlowSuccessComponent } from '../../app/book/flow-success.component'; - -describe('FlowSuccessComponent', () => { - const createComponent = createRoutingFactory({ - component: FlowSuccessComponent, - declarations: [MockComponent(IconComponent)], - }); - - beforeEach(() => createComponent()); - - it('should link to Schedule', () => { - expect('a').toContainText('Your Bookings'); - }); - - it('should link to new booking', () => { - expect('a').toContainText('New Booking'); - }); -}); diff --git a/apps/workplace/src/tests/book/parking-request-flow/parking-request-confirm.component.spec.ts b/apps/workplace/src/tests/book/parking-request-flow/parking-request-confirm.component.spec.ts deleted file mode 100644 index b6c8dfee1b4..00000000000 --- a/apps/workplace/src/tests/book/parking-request-flow/parking-request-confirm.component.spec.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { signal } from '@angular/core'; -import { MatBottomSheetRef } from '@angular/material/bottom-sheet'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { BookingFormService } from '@placeos/bookings'; -import { - OrganisationService, - SettingsService, - setNotifyOutlet, -} from '@placeos/common'; -import { MockProvider } from 'ng-mocks'; - -import { ParkingRequestConfirmComponent } from '../../../app/book/parking-request-flow/parking-request-confirm.component'; - -describe('ParkingRequestConfirmComponent', () => { - let spectator: Spectator; - let notify_open: any; - let model: ReturnType>>; - let loading: ReturnType>; - let post_form: any; - let dismiss: unknown; - - const createComponent = createComponentFactory({ - component: ParkingRequestConfirmComponent, - detectChanges: false, - providers: [ - MockProvider(BookingFormService, {}), - MockProvider(OrganisationService, {}), - MockProvider(SettingsService, {}), - MockProvider(MatBottomSheetRef, {}), - ], - }); - - beforeEach(() => { - notify_open = vi.fn(() => ({ - onAction: () => ({ subscribe: () => undefined }), - dismiss: () => undefined, - })); - setNotifyOutlet({ open: notify_open } as any, true); - model = signal({ - title: 'Parking Request', - date: Date.now(), - duration: 60, - all_day: false, - vehicle_type: 'car', - request_type: 'standard', - zones: ['bld-1'], - space_restrictions: 'oversized', - extra_space_restrictions: [], - }); - loading = signal(false); - post_form = vi.fn(() => Promise.resolve({ id: 'req-1' })); - dismiss = vi.fn(); - spectator = createComponent({ - providers: [ - MockProvider(BookingFormService, { - model, - loading, - postForm: post_form, - } as any), - MockProvider(OrganisationService, { - buildings: [ - { id: 'bld-1', name: 'HQ', display_name: 'Headquarters' }, - ], - } as any), - MockProvider(SettingsService, { - time_format: 'h:mm a', - }), - { provide: MatBottomSheetRef, useValue: { dismiss } }, - ], - }); - }); - - afterEach(() => setNotifyOutlet(null, true)); - - it('should dismiss with a truthy value when the request is posted successfully', async () => { - await spectator.component.postForm(); - - expect(post_form).toHaveBeenCalled(); - expect(dismiss).toHaveBeenCalledWith(true); - expect(notify_open).not.toHaveBeenCalled(); - }); - - it('should not dismiss when postForm resolves without a booking', async () => { - post_form.mockResolvedValueOnce(undefined); - - await spectator.component.postForm(); - - expect(dismiss).not.toHaveBeenCalled(); - }); - - it('should notify the user and not dismiss when submitting the request fails', async () => { - post_form.mockRejectedValueOnce('boom'); - - await spectator.component.postForm(); - - expect(notify_open).toHaveBeenCalledWith( - expect.stringContaining('Unable to submit parking request.'), - expect.anything(), - expect.anything(), - ); - expect(dismiss).not.toHaveBeenCalled(); - }); - - it('should resolve the location from the booking zones', () => { - expect(spectator.component.location).toBe('Headquarters'); - }); - - it('should map a known space restriction id to its configured label', () => { - spectator.component.space_restriction_options.set([ - { id: 'oversized', name: 'Oversized Vehicle' }, - ]); - expect(spectator.component.space_restriction_label).toBe( - 'Oversized Vehicle', - ); - }); - - it('should fall back to the raw value for an unknown space restriction id', () => { - spectator.component.space_restriction_options.set([]); - model.update((m) => ({ ...m, space_restrictions: 'unknown-id' })); - expect(spectator.component.space_restriction_label).toBe('unknown-id'); - }); - - it('should use a generic label when the space restriction is not a string', () => { - model.update((m) => ({ ...m, space_restrictions: true })); - expect(spectator.component.space_restriction_label).toBe( - 'BOOKINGS.PARKING_SPACE_RESTRICTIONS', - ); - }); - - it('should return an empty label when there is no space restriction', () => { - model.update((m) => ({ ...m, space_restrictions: null })); - expect(spectator.component.space_restriction_label).toBe(''); - }); - - it('should map extra space restriction ids to their configured labels', () => { - spectator.component.extra_space_restriction_options.set([ - { id: 'ev', name: 'EV Charging' }, - { id: 'accessible', name: 'Accessible' }, - ]); - model.update((m) => ({ - ...m, - extra_space_restrictions: ['ev', 'missing'], - })); - - expect(spectator.component.extra_space_restriction_labels).toEqual([ - 'EV Charging', - 'missing', - ]); - }); - - it('should return no extra labels when none are selected', () => { - model.update((m) => ({ ...m, extra_space_restrictions: [] })); - expect(spectator.component.extra_space_restriction_labels).toEqual([]); - }); -}); diff --git a/apps/workplace/src/tests/components/event-timeline.component.spec.ts b/apps/workplace/src/tests/components/event-timeline.component.spec.ts deleted file mode 100644 index 58f82e2c9a6..00000000000 --- a/apps/workplace/src/tests/components/event-timeline.component.spec.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { set } from 'date-fns'; - -import { EventTimelineComponent } from '../../app/components/event-timeline.component'; - -const REFERENCE_DAY = new Date(2026, 0, 15); -const at = (hours: number, minutes = 0) => - set(REFERENCE_DAY, { - hours, - minutes, - seconds: 0, - milliseconds: 0, - }).valueOf(); - -const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -describe('EventTimelineComponent', () => { - let spectator: Spectator; - const createComponent = createComponentFactory({ - component: EventTimelineComponent, - }); - - beforeEach(() => (spectator = createComponent())); - - it('generates 5-minute blocks between 06:30 and 21:30 on init', () => { - const blocks = spectator.component.model.blocks; - // (21:30 - 06:30) = 15h = 900min / 5min = 180 blocks - expect(blocks.length).toBe(180); - expect(blocks[0].id).toBe('06:30'); - expect(blocks[0].hour).toBe(false); - expect(blocks[0].divider).toBe(true); - const hour_block = blocks.find((b) => b.id === '07:00'); - expect(hour_block.hour).toBe(true); - expect(hour_block.divider).toBe(true); - }); - - it('derives selected period fractions from the active date/duration', () => { - spectator.setInput('date', at(9, 0)); - spectator.setInput('duration', 30); - // window is 06:30 -> 21:30 = 15h. 09:00 is 2.5h in => 2.5/15 - expect(spectator.component.model.start_time).toBeCloseTo(2.5 / 15, 3); - // 30 minutes of a 15h window - expect(spectator.component.model.duration).toBeCloseTo(0.5 / 15, 3); - }); - - it('selects a time when a block is clicked and emits the change', () => { - spectator.setInput('date', at(9, 0)); - let emitted: number | undefined; - spectator.component.date.subscribe((v) => (emitted = v)); - - spectator.component.setTime({ id: '10:15' }); - - const selected = new Date(spectator.component.date()); - expect(selected.getHours()).toBe(10); - expect(selected.getMinutes()).toBe(15); - expect(emitted).toBe(spectator.component.date()); - }); - - it('ignores block selection when no block is provided', () => { - const before = spectator.component.date(); - spectator.component.setTime(undefined); - expect(spectator.component.date()).toBe(before); - }); - - it('converts fractional hours to a 5-minute aligned timestamp', () => { - const result = new Date(spectator.component.hoursToDate(9.5)); - expect(result.getHours()).toBe(9); - expect(result.getMinutes()).toBe(30); - }); - - it('converts HH:mm strings rounding down to 5-minute alignment', () => { - const result = new Date(spectator.component.timeToDate('09:37')); - expect(result.getHours()).toBe(9); - expect(result.getMinutes()).toBe(35); - }); - - it('marks blocks overlapping a group event as unavailable', () => { - spectator.component.model.blocks = [ - { id: '09:00' }, - { id: '09:05' }, - { id: '09:10' }, - ]; - spectator.component.model.groups = [ - { events: [{ start: 9, duration: 10 }] }, - ]; - - spectator.component.checkInUseBlocks(); - - const blocks = spectator.component.model.blocks; - expect(blocks.find((b) => b.id === '09:00').unavailable).toBe(true); - expect(blocks.find((b) => b.id === '09:05').unavailable).toBe(true); - // 09:10 is the exclusive end of the event window - expect(blocks.find((b) => b.id === '09:10').unavailable).toBe(false); - }); - - it('tracks and resets the active move target', () => { - spectator.component.setMove('start'); - expect(spectator.component.model.move).toBe('start'); - spectator.component.resetMove(); - expect(spectator.component.model.move).toBe(''); - }); - - it('builds render groups with per-block events after group input changes', async () => { - spectator.setInput('groups', [ - { name: 'Team', events: [{ start: 9, duration: 5 }] }, - ]); - await wait(40); - - const groups = spectator.component.model.groups; - expect(groups.length).toBe(1); - expect(groups[0].name).toBe('Team'); - const total_events = groups[0].blocks.reduce( - (count, blk) => count + (blk.events?.length || 0), - 0, - ); - expect(total_events).toBe(1); - }); - - it('does not emit date changes while dragging is inactive', async () => { - spectator.component.model.move = ''; - let emitted = false; - spectator.component.date.subscribe(() => (emitted = true)); - - spectator.component.move({ clientX: 10, clientY: 10 }); - await wait(20); - - expect(emitted).toBe(false); - }); -}); diff --git a/apps/workplace/src/tests/components/user-availability.modal.component.spec.ts b/apps/workplace/src/tests/components/user-availability.modal.component.spec.ts deleted file mode 100644 index c7186441213..00000000000 --- a/apps/workplace/src/tests/components/user-availability.modal.component.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; -import * as ts_client from '@placeos/ts-client'; -import { getUnixTime } from 'date-fns'; - -import { UserAvailabilityModalComponent } from '../../app/components/user-availability.modal.component'; - -// Native vitest cannot module-mock the @placeos/events workspace package. -// `queryEvents` is the real function; it calls @placeos/ts-client `get` -// (the only mockable layer), stubbed to resolve `[]` below. -vi.mock('@placeos/ts-client', { spy: true }); - -describe('UserAvailabilityModalComponent', () => { - // base_data users have no email so ngOnInit's un-awaited - // loadAvailability() never reaches the query layer. - const base_data = { - users: [{ name: 'Alice' }, { name: 'Bob' }], - date: new Date('2050-01-01T09:00:00').valueOf(), - duration: 60, - }; - - let spectator: Spectator; - const createComponent = createComponentFactory({ - component: UserAvailabilityModalComponent, - detectChanges: false, - providers: [{ provide: MAT_DIALOG_DATA, useValue: base_data }], - }); - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(ts_client.get).mockResolvedValue([] as any); - spectator = createComponent(); - }); - - it('initialises state from dialog data on init', async () => { - await spectator.component.ngOnInit(); - expect(spectator.component.users()).toEqual(base_data.users); - expect(spectator.component.date()).toBe(base_data.date); - expect(spectator.component.duration()).toBe(60); - }); - - it('marks a past date via is_past and a future date as not past', () => { - spectator.component.date.set(new Date('2000-01-01').valueOf()); - expect(spectator.component.is_past()).toBe(true); - spectator.component.date.set(new Date('2100-01-01').valueOf()); - expect(spectator.component.is_past()).toBe(false); - }); - - it('emits a done event with date and duration on save when not past', () => { - const emit = vi.fn(); - spectator.component.event.subscribe(emit); - spectator.component.date.set(new Date('2100-06-01').valueOf()); - spectator.component.duration.set(90); - spectator.component.save(); - expect(emit).toHaveBeenCalledWith({ - reason: 'done', - metadata: { - date: spectator.component.date(), - duration: 90, - }, - }); - }); - - it('does not emit on save when the selected date is in the past', () => { - const emit = vi.fn(); - spectator.component.event.subscribe(emit); - spectator.component.date.set(new Date('2000-01-01').valueOf()); - spectator.component.save(); - expect(emit).not.toHaveBeenCalled(); - }); - - it('preserves hours and minutes when changing the date', () => { - const current = new Date('2050-03-03T14:37:00'); - spectator.component.date.set(current.valueOf()); - const new_day = new Date('2050-08-08T00:00:00').valueOf(); - spectator.component.changeDate(new_day); - const result = new Date(spectator.component.date()); - expect(result.getHours()).toBe(14); - expect(result.getMinutes()).toBe(37); - expect(result.getDate()).toBe(8); - expect(result.getMonth()).toBe(7); - }); - - it('builds a group per user and resets loading after availability loads', async () => { - spectator.component.users.set(base_data.users as any); - await spectator.component.loadAvailability(); - const groups = spectator.component.groups(); - expect(groups).toHaveLength(2); - expect(groups[0].name).toBe('Alice'); - expect(groups[1].name).toBe('Bob'); - expect(spectator.component.loading()).toBe(false); - }); - - it('returns empty events for users without an email without querying', async () => { - spectator.component.users.set([{ name: 'NoEmail' } as any]); - await spectator.component.loadAvailability(); - // No email => the component never reaches the query layer. - expect(ts_client.get).not.toHaveBeenCalled(); - expect(spectator.component.groups()).toEqual([ - { name: 'NoEmail', events: [] }, - ]); - }); - - it('maps queried events into timeline groups for users with an email', async () => { - const event_date = new Date('2050-01-01T10:00:00'); - vi.mocked(ts_client.get).mockResolvedValue([ - { event_start: getUnixTime(event_date), duration: 45 }, - ] as any); - spectator.component.users.set([ - { name: 'Alice', email: 'alice@place.tech' } as any, - ]); - await spectator.component.loadAvailability(); - expect(ts_client.get).toHaveBeenCalled(); - const groups = spectator.component.groups(); - expect(groups).toHaveLength(1); - expect(groups[0].events).toEqual([ - { start: event_date.getHours(), duration: 45 }, - ]); - expect(spectator.component.loading()).toBe(false); - }); -}); diff --git a/apps/workplace/src/tests/components/vertical-timeline.component.spec.ts b/apps/workplace/src/tests/components/vertical-timeline.component.spec.ts deleted file mode 100644 index 23ee6369c90..00000000000 --- a/apps/workplace/src/tests/components/vertical-timeline.component.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { set } from 'date-fns'; - -import { VerticalTimelineComponent } from '../../app/components/vertical-timeline.component'; - -const REFERENCE_DAY = new Date(2026, 0, 15); -const at = (hours: number, minutes = 0) => - set(REFERENCE_DAY, { - hours, - minutes, - seconds: 0, - milliseconds: 0, - }).valueOf(); - -const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -describe('VerticalTimelineComponent', () => { - let spectator: Spectator; - const createComponent = createComponentFactory({ - component: VerticalTimelineComponent, - }); - - beforeEach( - () => - (spectator = createComponent({ - props: { date: at(9, 0), duration: 60 }, - })), - ); - - it('generates a full day of 5-minute blocks on init', () => { - const blocks = spectator.component.blocks(); - // 24h * 60min / 5min = 288 blocks - expect(blocks.length).toBe(288); - expect(blocks[0].id).toBe('00:00'); - expect(blocks[0].hour).toBe(true); - expect(blocks[0].show).toBe(true); - const nine = blocks.find((b) => b.id === '09:00'); - expect(nine.hour).toBe(true); - }); - - it('renders a human readable selected range for the display label', () => { - const label = spectator.component.display; - expect(label).toContain('09:00 AM'); - expect(label).toContain('10:00 AM'); - }); - - it('derives active start and length fractions from date/duration', () => { - spectator.component.updateStartEnd(); - // 09:00 within a full day => 9/24 (allowing for endOfDay truncation) - expect(spectator.component.active_start()).toBeCloseTo(0.375, 2); - // 60 minutes within a full day - expect(spectator.component.active_length()).toBeCloseTo(0.0417, 2); - }); - - it('marks blocks overlapping a group event as unavailable', () => { - spectator.setInput('groups', [ - { events: [{ start: 9, duration: 10 }] }, - ]); - const blocks = spectator.component.blocks(); - expect(blocks.find((b) => b.id === '09:00').unavailable).toBe(true); - expect(blocks.find((b) => b.id === '09:05').unavailable).toBe(true); - expect(blocks.find((b) => b.id === '09:10').unavailable).toBe(false); - }); - - it('converts fractional hours to a 5-minute aligned timestamp', () => { - const result = new Date(spectator.component.hoursToDate(9.5)); - expect(result.getHours()).toBe(9); - expect(result.getMinutes()).toBe(30); - }); - - it('tracks and resets the active move target', () => { - spectator.component.setMove('top'); - expect(spectator.component.active_move).toBe('top'); - spectator.component.resetMove(); - expect(spectator.component.active_move).toBe(''); - }); - - it('does not emit date changes while dragging is inactive', async () => { - spectator.component.active_move = ''; - let emitted = false; - spectator.component.date.subscribe(() => (emitted = true)); - - spectator.component.move({ clientX: 10, clientY: 10 }); - await wait(20); - - expect(emitted).toBe(false); - }); -}); diff --git a/apps/workplace/src/tests/components/view-attendees-modal.component.spec.ts b/apps/workplace/src/tests/components/view-attendees-modal.component.spec.ts deleted file mode 100644 index 3ae2315046b..00000000000 --- a/apps/workplace/src/tests/components/view-attendees-modal.component.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { MockComponent } from 'ng-mocks'; -import { IconComponent } from '@placeos/components'; - -import { ViewAttendeesModalComponent } from '../../app/components/view-attendees-modal.component'; - -describe('ViewAttendeesModalComponent', () => { - const attendees = [ - { name: 'Alice', email: 'alice@place.tech' }, - { name: 'Bob', email: 'bob@place.tech' }, - ]; - - let spectator: Spectator; - const createComponent = createComponentFactory({ - component: ViewAttendeesModalComponent, - declarations: [MockComponent(IconComponent)], - detectChanges: false, - providers: [{ provide: MAT_DIALOG_DATA, useValue: { attendees } }], - }); - - beforeEach(() => { - spectator = createComponent(); - }); - - it('populates attendees from dialog data on init', () => { - spectator.detectChanges(); - expect(spectator.component.attendees()).toEqual(attendees); - }); - - it('renders a row per attendee with name and mailto link', () => { - spectator.detectChanges(); - const names = spectator - .queryAll('label') - .map((el) => el.textContent?.trim()); - expect(names).toEqual(['Alice', 'Bob']); - const links = spectator.queryAll('a'); - expect(links).toHaveLength(2); - expect(links[0].getAttribute('href')).toBe('mailto:alice@place.tech'); - expect(links[1].textContent?.trim()).toBe('bob@place.tech'); - }); -}); diff --git a/apps/workplace/src/tests/components/view-catering-modal.component.spec.ts b/apps/workplace/src/tests/components/view-catering-modal.component.spec.ts deleted file mode 100644 index e889a965ac1..00000000000 --- a/apps/workplace/src/tests/components/view-catering-modal.component.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog'; -import { Router } from '@angular/router'; -import { MockComponent, MockProvider } from 'ng-mocks'; -import { IconComponent } from '@placeos/components'; - -import { ViewCateringModalComponent } from '../../app/components/view-catering-modal.component'; - -describe('ViewCateringModalComponent', () => { - const data = { - catering: [ - { name: 'Coffee', quantity: 2 }, - { name: 'Tea', quantity: 3 }, - ], - catering_note: 'No nuts please', - }; - - let spectator: Spectator; - const createComponent = createComponentFactory({ - component: ViewCateringModalComponent, - declarations: [MockComponent(IconComponent)], - detectChanges: false, - providers: [ - { provide: MAT_DIALOG_DATA, useValue: data }, - MockProvider(Router, { navigate: vi.fn() }), - ], - }); - - beforeEach(() => { - spectator = createComponent(); - }); - - it('sets catering, note and total item count from dialog data on init', () => { - spectator.detectChanges(); - expect(spectator.component.catering()).toEqual(data.catering); - expect(spectator.component.catering_note()).toBe('No nuts please'); - expect(spectator.component.catering_items_total()).toBe(5); - }); - - it('closes all dialogs and navigates to the catering route on edit', () => { - spectator.detectChanges(); - const dialog = - spectator.fixture.componentRef.injector.get(MatDialog); - const close_all = vi.spyOn(dialog, 'closeAll').mockImplementation(() => undefined); - const router = spectator.inject(Router); - spectator.component.edit(); - expect(close_all).toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith(['/catering/']); - }); -}); diff --git a/apps/workplace/src/tests/landing/chat-view.component.spec.ts b/apps/workplace/src/tests/landing/chat-view.component.spec.ts deleted file mode 100644 index 70512d42c59..00000000000 --- a/apps/workplace/src/tests/landing/chat-view.component.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { MatBottomSheetRef } from '@angular/material/bottom-sheet'; -import { Component } from '@angular/core'; -import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/vitest'; -import { ChatViewComponent } from '../../app/landing-new/chat-view.component'; - -@Component({ template: '' }) -class BlankRouteComponent {} - -describe('ChatViewComponent', () => { - let spectator: SpectatorRouting; - const bottom_ref = { dismiss: vi.fn() }; - const createComponent = createRoutingFactory({ - component: ChatViewComponent, - stubsEnabled: false, - declarations: [BlankRouteComponent], - routes: [{ path: '**', component: BlankRouteComponent }], - providers: [{ provide: MatBottomSheetRef, useValue: bottom_ref }], - }); - - beforeEach(() => { - vi.clearAllMocks(); - spectator = createComponent(); - }); - - it('should dismiss the bottom sheet when closed', () => { - spectator.component.close(); - expect(bottom_ref.dismiss).toHaveBeenCalled(); - }); - - it('should greet the current user', () => { - (spectator.component.user as any).set({ name: 'Jane Doe' }); - spectator.detectChanges(); - expect(spectator.query('h2')?.textContent).toContain('Jane Doe'); - }); - - it('should close the sheet when a quick action link is clicked', () => { - spectator.detectChanges(); - const links = spectator.queryAll('a[btn]'); - expect(links.length).toBe(4); - spectator.click(links[0]); - expect(bottom_ref.dismiss).toHaveBeenCalled(); - }); - - it('should route quick actions to the booking flows', () => { - spectator.detectChanges(); - const links = spectator.queryAll('a[btn]'); - const hrefs = links.map((a) => a.getAttribute('href')); - expect(hrefs).toEqual([ - '/book/meeting', - '/book/desk', - '/your-bookings', - '/book/parking', - ]); - }); -}); diff --git a/apps/workplace/src/tests/landing/landing-virtual-concierge.component.spec.ts b/apps/workplace/src/tests/landing/landing-virtual-concierge.component.spec.ts deleted file mode 100644 index f8d2bcce819..00000000000 --- a/apps/workplace/src/tests/landing/landing-virtual-concierge.component.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { signal } from '@angular/core'; -import { SettingsService } from '@placeos/common'; -import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/vitest'; -import { LandingVirtualConciergeComponent } from '../../app/landing-new/landing-virtual-concierge.component'; - -describe('LandingVirtualConciergeComponent', () => { - let spectator: SpectatorRouting; - const prompt_config = signal(null); - const settings_service = { - signal: vi.fn((key: string) => - key === 'virtual_concierge.prompts' ? prompt_config : signal(null), - ), - }; - const createComponent = createRoutingFactory({ - component: LandingVirtualConciergeComponent, - stubsEnabled: false, - detectChanges: false, - providers: [{ provide: SettingsService, useValue: settings_service }], - }); - - beforeEach(() => { - vi.clearAllMocks(); - prompt_config.set(null); - spectator = createComponent(); - spectator.component.features.set([]); - }); - - it('should fall back to default prompts when none are configured', () => { - expect(spectator.component.prompts()).toEqual([ - 'Book a meeting room tomorrow at 2pm for 4 people', - 'Find me an available desk near a window', - 'Reserve parking for Friday morning', - ]); - }); - - it('should use configured prompts when they are an array', () => { - prompt_config.set(['Ask me one thing', 'Ask me another']); - expect(spectator.component.prompts()).toEqual([ - 'Ask me one thing', - 'Ask me another', - ]); - }); - - it('should toggle fullscreen state', () => { - expect(spectator.component.is_fullscreen()).toBe(false); - spectator.component.openFullscreen(); - expect(spectator.component.is_fullscreen()).toBe(true); - spectator.component.closeFullscreen(); - expect(spectator.component.is_fullscreen()).toBe(false); - }); - - it('should render the compact card and hide the overlay by default', () => { - spectator.detectChanges(); - expect(spectator.query('.fixed.inset-0')).toBeFalsy(); - expect(spectator.query('img[src="assets/icons/roybot.png"]')).toBeTruthy(); - }); - - it('should render the fullscreen overlay when opened', () => { - spectator.component.openFullscreen(); - spectator.detectChanges(); - expect(spectator.query('.fixed.inset-0')).toBeTruthy(); - }); - - it('should gate quick action links by enabled features', () => { - spectator.component.features.set(['spaces', 'lockers']); - spectator.detectChanges(); - const hrefs = spectator - .queryAll('a[btn]') - .map((a) => a.getAttribute('href')); - expect(hrefs).toContain('/book/meeting'); - expect(hrefs).toContain('/book/locker'); - expect(hrefs).not.toContain('/book/desk'); - expect(hrefs).not.toContain('/book/parking'); - }); - - it('should close the overlay when the backdrop is clicked', () => { - spectator.component.openFullscreen(); - spectator.detectChanges(); - spectator.click('.fixed.inset-0'); - expect(spectator.component.is_fullscreen()).toBe(false); - }); -}); diff --git a/docs/settings/concierge.md b/docs/settings/concierge.md index d0fe23e735b..cf0ba265044 100644 --- a/docs/settings/concierge.md +++ b/docs/settings/concierge.md @@ -327,7 +327,6 @@ Each custom column requires a `field` (the booking extension data field to displ | Setting | Type | Default | Description | |---|---|---|---| -| `reports.features` | string[] | – | List of report cards shown on the reports menu page: `attendance`, `desks`, `spaces`, `catering`, `contact-tracing` and `user`. Defaults to all of them except `user`. The `/reports` route opens the desk report, so this page does not show at this time. The sidebar report links use the `*-report` values of `features`. | | `reports.attendance_include_weekends` | boolean | `false` | Include weekends in attendance report calculations. | | `reports.ignore_days` | array | – | Days of the week to exclude from report calculations. Either day names (e.g. `"saturday"`) or day indexes (0-6). | | `custom_reports` | array | `[]` | Custom report pages to add to the reports section. Each report is rendered as an embedded page. The sidebar shows a link to each report, whatever the value of `features`. | diff --git a/docs/settings/workplace.md b/docs/settings/workplace.md index 4b4dcdf50e3..2f173dfeee4 100644 --- a/docs/settings/workplace.md +++ b/docs/settings/workplace.md @@ -501,7 +501,6 @@ A button for the virtual concierge shows on the schedule, team schedule, explore | `virtual_concierge.icon` | string | – | URL of the image for the button. A default icon shows when this is empty. | | `virtual_concierge.display` | string | `"fullscreen"` | How the embed opens: `fullscreen` or `sidebar`. | | `virtual_concierge.side` | string | `"left"` | Side of the screen for the sidebar: `left` or `right`. | -| `virtual_concierge.prompts` | string[] | three example prompts | Suggested prompts for the virtual concierge UI. | ```json { diff --git a/e2e/support/visitor/your-bookings.page.ts b/e2e/support/visitor/your-bookings.page.ts index 81dac007bc3..2609afedd53 100644 --- a/e2e/support/visitor/your-bookings.page.ts +++ b/e2e/support/visitor/your-bookings.page.ts @@ -143,8 +143,7 @@ export class YourBookingsPage { * clock problem. * * Driving the sidebar calendar is the only stable way to move the view — - * there is no date in the URL, and `schedule-mobile-calendar` is `sm:hidden` - * so it does not exist at the desktop viewport the suite runs at. + * there is no date in the URL. * * Resolve the date from the displayed month and its rendered day-1 position. * Recalculate after loading, since selecting an adjacent-month day can From c1439f9395342e629605aac4a512c70cc8d632d6 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 17:07:52 +1100 Subject: [PATCH 02/21] refactor(apps): remove unused members from live files Remove methods, fields, injections and template blocks that nothing reads in workplace, concierge, map-kiosk, visitor-kiosk and booking-panel, with the spec cases that only tested them. --- .../src/app/checkin/checkin-view.component.ts | 64 +--------- .../src/app/event-panel.component.ts | 4 - .../new-panel/panel-view-details.component.ts | 4 - .../src/app/panel-state.service.ts | 109 ------------------ .../checkin/checkin-view.component.spec.ts | 7 +- ...set-category-management-modal.component.ts | 10 +- .../asset-group-form.component.ts | 2 - .../asset-manager-state.service.ts | 5 - .../asset-manager-topbar.component.ts | 2 - .../asset-manager/asset-manager.component.ts | 25 +--- .../asset-request-details.component.ts | 3 +- .../building-list.component.ts | 18 --- .../src/app/day-view/day-view.component.ts | 23 +--- .../src/app/day-view/events-state.service.ts | 9 -- .../day-view/room-week-timeline.component.ts | 2 - .../src/app/deals/deals.component.ts | 25 +--- apps/concierge/src/app/deals/deals.service.ts | 4 - .../src/app/desks/desk-bookings.component.ts | 1 - .../src/app/desks/desk-modal.component.ts | 2 - .../src/app/desks/desks-manage.component.ts | 3 +- .../src/app/desks/desks-state.service.ts | 16 --- .../src/app/desks/desks.component.ts | 8 +- .../email-templates.component.ts | 25 +--- .../app/facilities/facilities.component.ts | 24 +--- .../lockers/locker-booking-modal.component.ts | 7 +- .../src/app/lockers/locker-list.component.ts | 65 +---------- .../src/app/lockers/locker-state.service.ts | 44 ------- .../src/app/lockers/lockers.component.ts | 36 +----- .../app/map-manager/map-management.service.ts | 5 - .../src/app/parking/parking-state.service.ts | 26 ----- .../app/poi-manager/poi-manager.component.ts | 4 - .../src/app/points/points-topbar.component.ts | 24 +--- .../reports/desks/report-desks.component.ts | 8 -- .../app/reports/reports-options.component.ts | 8 -- .../src/app/reports/reports-state.service.ts | 11 +- .../room-manager-topbar.component.ts | 11 -- .../room-manager/room-manager.component.ts | 4 - .../app/room-manager/room-modal.component.ts | 8 -- .../src/app/staff/staff-topbar.component.ts | 4 +- .../src/app/surveys/new-survey.service.ts | 34 +----- .../src/app/surveys/survey.service.ts | 94 +-------------- .../app/url-management/url-list.component.ts | 8 +- .../url-management/url-manager.component.ts | 4 - .../app/visitors/guest-listing.component.ts | 15 --- .../asset-manager.component.spec.ts | 37 ------ .../src/tests/deals/deals.component.spec.ts | 27 ----- .../email-templates.component.spec.ts | 44 ------- .../facilities/facilities.component.spec.ts | 35 ------ .../lockers/locker-list.component.spec.ts | 34 ------ .../tests/lockers/lockers.component.spec.ts | 39 +------ .../map-management.service.spec.ts | 3 - .../tests/surveys/new-survey.service.spec.ts | 51 +------- .../src/tests/surveys/survey.service.spec.ts | 77 +------------ .../url-management/url-list.component.spec.ts | 11 +- .../app/accessibility-controls.component.ts | 46 -------- apps/map-kiosk/src/app/explore.component.ts | 13 --- apps/map-kiosk/src/app/parking.component.ts | 12 +- .../accessibility-controls.component.spec.ts | 20 ---- .../src/app/bootstrap.component.ts | 13 +-- .../src/app/welcome.component.ts | 4 - .../book/desk-flow-new/desk-flow.component.ts | 10 -- .../src/app/book/desk-flow.component.ts | 4 - .../src/app/book/locker-flow.component.ts | 4 - .../src/app/book/meeting-flow.component.ts | 2 - .../src/app/book/parking-flow.component.ts | 4 - .../src/app/landing/landing-state.service.ts | 4 - .../landing-upcoming.component.spec.ts | 1 - 67 files changed, 47 insertions(+), 1263 deletions(-) delete mode 100644 apps/concierge/src/tests/asset-manager/asset-manager.component.spec.ts delete mode 100644 apps/concierge/src/tests/deals/deals.component.spec.ts delete mode 100644 apps/concierge/src/tests/email-templates/email-templates.component.spec.ts delete mode 100644 apps/concierge/src/tests/facilities/facilities.component.spec.ts diff --git a/apps/booking-panel/src/app/checkin/checkin-view.component.ts b/apps/booking-panel/src/app/checkin/checkin-view.component.ts index a5b97606cfb..c4756b63b78 100644 --- a/apps/booking-panel/src/app/checkin/checkin-view.component.ts +++ b/apps/booking-panel/src/app/checkin/checkin-view.component.ts @@ -10,7 +10,7 @@ import { ActivatedRoute } from '@angular/router'; import { AsyncHandler, notifyError } from '@placeos/common'; import { MatRippleModule } from '@angular/material/core'; -import { IconComponent, TranslatePipe } from '@placeos/components'; +import { TranslatePipe } from '@placeos/components'; import { getNextFreeTimeSlot } from '@placeos/events'; import { currentPeriod, nextPeriod } from '../new-panel/helpers'; import { PanelStateService } from '../panel-state.service'; @@ -176,61 +176,6 @@ const SLOT_MINUTES = 15; (event)="bookSlot($event)" >
- @if (false) { -

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

- } - @if (false) { -
- - - - -
- } `, styles: [ ` @@ -246,12 +191,7 @@ const SLOT_MINUTES = 15; `, ], providers: [PanelStateService], - imports: [ - IconComponent, - TranslatePipe, - MatRippleModule, - CheckinTimetableComponent, - ], + imports: [TranslatePipe, MatRippleModule, CheckinTimetableComponent], }) export class CheckinViewComponent extends AsyncHandler implements OnInit { private _state = inject(PanelStateService); diff --git a/apps/booking-panel/src/app/event-panel.component.ts b/apps/booking-panel/src/app/event-panel.component.ts index 8c8c31f3c1c..f82d5b7c713 100644 --- a/apps/booking-panel/src/app/event-panel.component.ts +++ b/apps/booking-panel/src/app/event-panel.component.ts @@ -261,10 +261,6 @@ export class EventPanelComponent extends AsyncHandler implements OnInit { return this._state.setting('show_qr_code') !== false; } - public get custom_qr() { - return !!this._state.setting('custom_qr_url'); - } - public async ngOnInit() { await this._org.waitUntilInitialised(); const params = this._route.snapshot.paramMap; diff --git a/apps/booking-panel/src/app/new-panel/panel-view-details.component.ts b/apps/booking-panel/src/app/new-panel/panel-view-details.component.ts index b0dcbcd9bfb..4834f7ccb11 100644 --- a/apps/booking-panel/src/app/new-panel/panel-view-details.component.ts +++ b/apps/booking-panel/src/app/new-panel/panel-view-details.component.ts @@ -180,10 +180,6 @@ export class PanelViewDetailsComponent extends AsyncHandler implements OnInit { return this._state.setting('show_qr_code') !== false; } - public get custom_qr() { - return !!this._state.setting('custom_qr_url'); - } - public get hide_qr_text() { return this._state.setting('hide_qr_text'); } diff --git a/apps/booking-panel/src/app/panel-state.service.ts b/apps/booking-panel/src/app/panel-state.service.ts index 3c4c006e3f0..674a20ecad4 100644 --- a/apps/booking-panel/src/app/panel-state.service.ts +++ b/apps/booking-panel/src/app/panel-state.service.ts @@ -29,7 +29,6 @@ import { notifyWarn, SettingsService, Space, - timePeriodsIntersect, } from '@placeos/common'; import { EventFormService, updateEvent } from '@placeos/events'; @@ -132,26 +131,6 @@ export interface PanelSettings { timeline_position?: PanelTimelinePosition; } -export function currentBooking( - list: CalendarEvent[], - date: number = new Date().valueOf(), -) { - return list.find((bkn) => - timePeriodsIntersect( - date, - date, - bkn.date, - bkn.date + bkn.duration * 1000, - ), - ); -} - -export type CalendarEventStatus = - | 'available' - | 'pending' - | 'busy' - | 'not-bookable'; - declare global { interface Window { panel_module?: PlaceModuleBinding; @@ -177,8 +156,6 @@ export class PanelStateService extends AsyncHandler { private _load_id = 0; /** Current time. Updates every 5 seconds */ public readonly clock = this._clock.asReadonly(); - /** Whether the websocket to the PlaceOS backend is connected */ - public readonly connected = signal(true); /** Time the websocket disconnected. `0` while connected */ public readonly offline_since = signal(0); private _bound_system = ''; @@ -356,7 +333,6 @@ export class PanelStateService extends AsyncHandler { this.subscription( 'websocket-status', websocketStatus().subscribe((online) => { - this.connected.set(online); if (online) this.offline_since.set(0); else if (!this.offline_since()) this.offline_since.set(Date.now()); @@ -425,9 +401,6 @@ export class PanelStateService extends AsyncHandler { future = false, force_api = false, ) { - // if (date <= Date.now() && !user) { - // return this.confirmBookNow(); - // } const current = this._current(); if ( current && @@ -495,61 +468,6 @@ export class PanelStateService extends AsyncHandler { } } - public async confirmBookNow() { - this.timeout( - 'reset_view', - () => this._dialog.closeAll(), - 2 * 60 * 1000, - ); - const date = Date.now(); - const current = this._current(); - if ( - current && - isAfter(date, current.date) && - isBefore(date, addMinutes(current.date, current.duration)) - ) { - return notifyError('Booking already exists for this time'); - } - let max_duration = undefined; - const next = this._next(); - if (next && date <= Date.now()) { - const diff = Math.abs(differenceInMinutes(next.date, date)); - const max = this._settings().max_duration || 480; - max_duration = diff < max ? diff : max; - } - if (max_duration != null && max_duration < 15) { - return notifyError( - 'Unable to make bookings as the time available before the next meeting is less than 15 minutes', - ); - } - const ref = await openConfirmModal( - { - title: 'Book Meeting', - content: `Do you wish to book a meeting in this room for ${Math.min( - max_duration || 180, - 30, - )} minutes?`, - icon: { content: 'event' }, - }, - this._dialog, - ); - if (ref.reason !== 'done') return; - ref.loading('Creating Meeting...'); - try { - const module = getModule(this.system, 'Bookings'); - if (!module) throw 'Unable to find module'; - await module.execute('book_now', [ - Math.min(max_duration || 180, 30) * 60, - 'Ad-hoc Panel Booking', - ]); - notifySuccess('Successfully created meeting.'); - } catch (e) { - notifyError(`Error creating meeting. ${e}`); - } - ref.close(); - this.clearTimeout('reset_view'); - } - /** * Book the room from now for `minutes` without the booking form. * The signed-in user is the host. @@ -636,33 +554,6 @@ export class PanelStateService extends AsyncHandler { notifySuccess('Successfully created meeting.'); } - /** - * Open confirmation modal for starting the meeting - */ - public async confirmStart() { - this.timeout( - 'reset_view', - () => this._dialog.closeAll(), - 2 * 60 * 1000, - ); - const details = await openConfirmModal( - { - title: 'Do you wish to start your meeting?', - content: `If you don't start your meeting it will be cancelled ${ - this._settings().pending_period - } minutes after the start time.`, - icon: { - class: 'material-symbols-rounded', - content: 'play_arrow', - }, - }, - this._dialog, - ); - if (details.reason !== 'done') return; - this.startMeeting(); - this.clearTimeout('reset_view'); - } - /** * Execute the logic on the engine driver to start the current or upcoming meeting */ diff --git a/apps/booking-panel/src/tests/checkin/checkin-view.component.spec.ts b/apps/booking-panel/src/tests/checkin/checkin-view.component.spec.ts index 3d5f6354e77..742a41813cf 100644 --- a/apps/booking-panel/src/tests/checkin/checkin-view.component.spec.ts +++ b/apps/booking-panel/src/tests/checkin/checkin-view.component.spec.ts @@ -4,7 +4,7 @@ import { SpectatorRouting, } from '@ngneat/spectator/vitest'; import { mockComponent } from '@placeos/common/tests'; -import { IconComponent, TranslatePipe } from '@placeos/components'; +import { TranslatePipe } from '@placeos/components'; import { ngMocks } from 'ng-mocks'; import { CheckinTimetableComponent } from '../../app/checkin/checkin-timetable.component'; @@ -40,10 +40,7 @@ describe('CheckinViewComponent', () => { component: CheckinViewComponent, detectChanges: false, params: { system_id: 'sys-42' }, - declarations: [ - mockComponent(CheckinTimetableComponent), - mockComponent(IconComponent), - ], + declarations: [mockComponent(CheckinTimetableComponent)], componentProviders: [ { provide: PanelStateService, useValue: state_service }, ], diff --git a/apps/concierge/src/app/asset-manager/asset-category-management-modal.component.ts b/apps/concierge/src/app/asset-manager/asset-category-management-modal.component.ts index 243cca0d091..b7b253b813c 100644 --- a/apps/concierge/src/app/asset-manager/asset-category-management-modal.component.ts +++ b/apps/concierge/src/app/asset-manager/asset-category-management-modal.component.ts @@ -1,10 +1,6 @@ import { Component, EventEmitter, Signal, inject } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; -import { - MAT_DIALOG_DATA, - MatDialogModule, - MatDialogRef, -} from '@angular/material/dialog'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { MatTooltipModule } from '@angular/material/tooltip'; import { AssetCategory } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; @@ -97,10 +93,6 @@ export class AssetCategoryManagementModalComponent { list: Signal; edit: (i?) => any; }>(MAT_DIALOG_DATA); - private _dialog_ref = - inject>( - MatDialogRef, - ); public readonly changed = new EventEmitter(); public readonly list = this._data.list; diff --git a/apps/concierge/src/app/asset-manager/asset-group-form.component.ts b/apps/concierge/src/app/asset-manager/asset-group-form.component.ts index a714b816685..f9c0cbbb16e 100644 --- a/apps/concierge/src/app/asset-manager/asset-group-form.component.ts +++ b/apps/concierge/src/app/asset-manager/asset-group-form.component.ts @@ -1,6 +1,5 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core'; import { FormField } from '@angular/forms/signals'; -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'; @@ -161,7 +160,6 @@ export class AssetGroupFormComponent extends AsyncHandler implements OnInit { private readonly _uses_lists = this._state.use('categories'); private _route = inject(ActivatedRoute); private _router = inject(Router); - private _dialog = inject(MatDialog); private readonly _form_ref = generateAssetGroupForm(); public readonly form = this._form_ref.form; diff --git a/apps/concierge/src/app/asset-manager/asset-manager-state.service.ts b/apps/concierge/src/app/asset-manager/asset-manager-state.service.ts index 1392206606e..f2cf0343530 100644 --- a/apps/concierge/src/app/asset-manager/asset-manager-state.service.ts +++ b/apps/concierge/src/app/asset-manager/asset-manager-state.service.ts @@ -71,7 +71,6 @@ export type AssetManagerList = export interface AssetOptions { date?: number; search?: string; - sort_by?: string; view: 'grid' | 'list'; active_item?: string; } @@ -368,10 +367,6 @@ export class AssetManagerStateService extends AsyncHandler { return this._form_ref.model; } - public get is_new_ui() { - return true; - } - public get base_route() { return '/book/assets'; } diff --git a/apps/concierge/src/app/asset-manager/asset-manager-topbar.component.ts b/apps/concierge/src/app/asset-manager/asset-manager-topbar.component.ts index d983565ffb8..ed5c0851d2b 100644 --- a/apps/concierge/src/app/asset-manager/asset-manager-topbar.component.ts +++ b/apps/concierge/src/app/asset-manager/asset-manager-topbar.component.ts @@ -13,7 +13,6 @@ import { notifySuccess, OrganisationService, settingSignal, - SettingsService, } from '@placeos/common'; import { AvailableRoomsStateModalComponent, @@ -195,7 +194,6 @@ import { AssetManagerStateService } from './asset-manager-state.service'; export class AssetManagerTopbarComponent extends AsyncHandler { private _state = inject(AssetManagerStateService); private _org = inject(OrganisationService); - private _settings = inject(SettingsService); private _dialog = inject(MatDialog); public readonly active = input(''); diff --git a/apps/concierge/src/app/asset-manager/asset-manager.component.ts b/apps/concierge/src/app/asset-manager/asset-manager.component.ts index 217dc0c01f3..5dd90453dbe 100644 --- a/apps/concierge/src/app/asset-manager/asset-manager.component.ts +++ b/apps/concierge/src/app/asset-manager/asset-manager.component.ts @@ -1,6 +1,5 @@ -import { Component, computed, inject, signal } from '@angular/core'; -import { Router, RouterModule } from '@angular/router'; -import { AsyncHandler } from '@placeos/common'; +import { Component } from '@angular/core'; +import { RouterModule } from '@angular/router'; import { ApplicationSidebarComponent } from '../ui/app-sidebar.component'; @Component({ @@ -26,22 +25,4 @@ import { ApplicationSidebarComponent } from '../ui/app-sidebar.component'; ], imports: [ApplicationSidebarComponent, RouterModule], }) -export class AssetManagerComponent extends AsyncHandler { - private _router = inject(Router); - - private readonly _url = signal(null); - - public readonly path = computed(() => { - this._url(); - const parts = this._router.url.split('/'); - return parts[parts.length - 1].split('?')[0]; - }); - - constructor() { - super(); - this.subscription( - 'router', - this._router.events.subscribe((event) => this._url.set(event)), - ); - } -} +export class AssetManagerComponent {} diff --git a/apps/concierge/src/app/asset-manager/asset-request-details.component.ts b/apps/concierge/src/app/asset-manager/asset-request-details.component.ts index faf84677d73..f56905a332b 100644 --- a/apps/concierge/src/app/asset-manager/asset-request-details.component.ts +++ b/apps/concierge/src/app/asset-manager/asset-request-details.component.ts @@ -11,7 +11,7 @@ import { } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatMenuModule } from '@angular/material/menu'; -import { OrganisationService, SettingsService } from '@placeos/common'; +import { SettingsService } from '@placeos/common'; import { DurationPipe, IconComponent, @@ -298,7 +298,6 @@ import { SplitJoinPipe } from './split-join.pipe'; }) export class AssetRequestDetailsComponent { private _state = inject(AssetManagerStateService); - private _org = inject(OrganisationService); private _settings = inject(SettingsService); public readonly requestInput = input(undefined, { alias: 'request' }); diff --git a/apps/concierge/src/app/building-manager/building-list.component.ts b/apps/concierge/src/app/building-manager/building-list.component.ts index 24ab0d3dd84..ef17314e212 100644 --- a/apps/concierge/src/app/building-manager/building-list.component.ts +++ b/apps/concierge/src/app/building-manager/building-list.component.ts @@ -6,9 +6,7 @@ import { BuildingManagementService } from './building-management.service'; import { MatMenuModule } from '@angular/material/menu'; import { - AuthenticatedImageDirective, IconComponent, - LevelPipe, SimpleTableComponent, TranslatePipe, } from '@placeos/components'; @@ -75,19 +73,6 @@ import {
{{ data }}
- - {{ (data | level)?.display_name || (data | level)?.name }} - - - @if (data.length) { - - } - @if (!data.length) { - {{ - 'COMMON.IMAGES_EMPTY' | translate - }} - } -
} - `, styles: [``], imports: [MatRippleModule, MatSlideToggleModule, FormsModule], @@ -60,8 +27,6 @@ import { AsyncHandler, SettingsService } from '@placeos/common'; export class AccessibilityControlsComponent extends AsyncHandler { private _settings = inject(SettingsService); private _allow_dark_mode = this._settings.signal('allow_dark_mode', false); - private _accessible = this._settings.signal('accessible', false, true); - private _font_size = this._settings.signal('font_size', 16, true); public readonly can_change_dark_mode = computed( () => !!this._allow_dark_mode(), @@ -73,17 +38,6 @@ export class AccessibilityControlsComponent extends AsyncHandler { this._settings.theme_signal() === 'dark', ); - public readonly accessible = computed(() => !!this._accessible()); - - public readonly font_size = computed(() => this._font_size() || 16); - - public readonly applySetting = (n, v) => - this.timeout( - 'apply_setting', - () => this._settings.saveUserSetting(n, v), - 1000, - ); - public setDarkMode(state: boolean) { this.timeout( 'dark_mode', diff --git a/apps/map-kiosk/src/app/explore.component.ts b/apps/map-kiosk/src/app/explore.component.ts index da20816dbb1..f3d8b3a3861 100644 --- a/apps/map-kiosk/src/app/explore.component.ts +++ b/apps/map-kiosk/src/app/explore.component.ts @@ -26,7 +26,6 @@ import { OrganisationService, Point, SettingsService, - unique, User, } from '@placeos/common'; import { @@ -368,8 +367,6 @@ export class ExploreComponent extends AsyncHandler implements OnInit { public readonly actions = this._state.map_actions; /** Signal for the labels map */ public readonly labels = this._state.map_labels; - /** Signal for the active map */ - public readonly options = this._state.options; public readonly locate = signal(''); @@ -397,7 +394,6 @@ export class ExploreComponent extends AsyncHandler implements OnInit { @HostListener('window:touchstart') public onTouch = () => this._restartReset(); - public readonly setOptions = (o) => this._state.setOptions(o); public readonly setLevel = (lvl) => this._state.setLevel(lvl.id); public updateZoom(zoom: number) { @@ -408,15 +404,6 @@ export class ExploreComponent extends AsyncHandler implements OnInit { this._state.setPositions(this._state.positions.zoom, center); } - public async toggleZones(enabled: boolean) { - const options = this.options(); - const disable = !enabled - ? unique([...(options.disable || []), 'zones', 'devices']) - : options.disable.filter((_) => _ !== 'zones' && _ !== 'devices') || - []; - this.setOptions({ disable }); - } - public readonly can_search = computed( () => !!this._settings.signal('explore.search_enabled')(), ); diff --git a/apps/map-kiosk/src/app/parking.component.ts b/apps/map-kiosk/src/app/parking.component.ts index 5dfce7b43f6..1e0fcaf1579 100644 --- a/apps/map-kiosk/src/app/parking.component.ts +++ b/apps/map-kiosk/src/app/parking.component.ts @@ -11,12 +11,7 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, ParamMap, Router } from '@angular/router'; -import { - log, - OrganisationService, - Point, - SettingsService, -} from '@placeos/common'; +import { log, OrganisationService, Point } from '@placeos/common'; import { SpacesService } from '@placeos/events'; import { ExploreParkingService, ExploreStateService } from '@placeos/explore'; @@ -81,7 +76,6 @@ export class ParkingComponent implements OnInit { private _dialog = inject(MatDialog); private _router = inject(Router); private _spaces = inject(SpacesService); - private _settings = inject(SettingsService); /** Signal of the current route query parameters */ private readonly _query_params = toSignal(this._route.queryParamMap); @@ -114,8 +108,6 @@ export class ParkingComponent implements OnInit { /** Signal for the available parking spaces */ public readonly available_spaces = this._parking.available_spaces; - public reset_delay = 180; - public readonly counts = computed(() => { const spaces = this.active_spaces(); const available = this.available_spaces(); @@ -137,8 +129,6 @@ export class ParkingComponent implements OnInit { while (!this._spaces.initialised()) { await new Promise((resolve) => setTimeout(resolve, 100)); } - this.reset_delay = - this._settings.get('app.inactivity_timeout_secs') || 180; this.resetKiosk(false); VirtualKeyboardComponent.enabled = localStorage.getItem('OSK.enabled') === 'true'; diff --git a/apps/map-kiosk/src/tests/accessibility-controls.component.spec.ts b/apps/map-kiosk/src/tests/accessibility-controls.component.spec.ts index 3d721609462..0ba5f110e15 100644 --- a/apps/map-kiosk/src/tests/accessibility-controls.component.spec.ts +++ b/apps/map-kiosk/src/tests/accessibility-controls.component.spec.ts @@ -9,8 +9,6 @@ import { AccessibilityControlsComponent } from '../app/accessibility-controls.co const setting_signals: Record> = { allow_dark_mode: signal(false), - accessible: signal(false), - font_size: signal(16), }; const theme = signal('light'); @@ -23,7 +21,6 @@ describe('AccessibilityControlsComponent', () => { signal: vi.fn((name: string) => setting_signals[name]) as any, theme_signal: theme, setTheme: vi.fn(), - saveUserSetting: vi.fn(), }), ], imports: [MockModule(MatSlideToggleModule), FormsModule], @@ -32,8 +29,6 @@ describe('AccessibilityControlsComponent', () => { beforeEach(() => { vi.clearAllMocks(); setting_signals.allow_dark_mode.set(false); - setting_signals.accessible.set(false); - setting_signals.font_size.set(16); theme.set('light'); spectator = createComponent(); }); @@ -93,19 +88,4 @@ describe('AccessibilityControlsComponent', () => { await vi.advanceTimersByTimeAsync(100); expect(settings.setTheme).not.toHaveBeenCalled(); }); - - it('should save user settings after a delay', async () => { - vi.useFakeTimers(); - const settings = spectator.inject(SettingsService); - spectator.component.applySetting('font_size', 20); - expect(settings.saveUserSetting).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1000); - expect(settings.saveUserSetting).toHaveBeenCalledWith('font_size', 20); - }); - - it('should fall back to a default font size', () => { - setting_signals.font_size.set(0); - spectator.detectChanges(); - expect(spectator.component.font_size()).toBe(16); - }); }); diff --git a/apps/visitor-kiosk/src/app/bootstrap.component.ts b/apps/visitor-kiosk/src/app/bootstrap.component.ts index 0bb188c32c0..4860764d2e2 100644 --- a/apps/visitor-kiosk/src/app/bootstrap.component.ts +++ b/apps/visitor-kiosk/src/app/bootstrap.component.ts @@ -582,23 +582,12 @@ export class BootstrapComponent extends AsyncHandler implements OnInit { this._router.navigate(route); } - private getActionParamFromUrl() { - return ( - this.getMergedQueryParamsFromUrl().action?.trim().toLowerCase() || - '' - ); - } - private handleQueryParams() { const query_params = this.getMergedQueryParamsFromUrl(); const query_key = JSON.stringify(query_params); if (query_key === this._last_query_params) return; this._last_query_params = query_key; - if (query_params.action) { - this._startup_action = query_params.action.trim().toLowerCase(); - } else { - this._startup_action = this.getActionParamFromUrl(); - } + this._startup_action = query_params.action?.trim().toLowerCase() || ''; if (query_params.osk !== undefined) { const osk_enabled = query_params.osk === 'true'; localStorage.setItem('OSK.enabled', `${osk_enabled}`); diff --git a/apps/visitor-kiosk/src/app/welcome.component.ts b/apps/visitor-kiosk/src/app/welcome.component.ts index d232d1215fe..c3dbf7aab25 100644 --- a/apps/visitor-kiosk/src/app/welcome.component.ts +++ b/apps/visitor-kiosk/src/app/welcome.component.ts @@ -264,10 +264,6 @@ export class WelcomeComponent public ngOnInit() { this.level.set(localStorage?.getItem('KIOSK.level')); - const params = this.route.snapshot.paramMap; - if (params.has('level')) { - this.level.set(params.get('level')); - } const query_params = this.route.snapshot.queryParamMap; if (query_params.has('osk')) { const osk_enabled = query_params.get('osk') === 'true'; diff --git a/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts index 99733277916..fc0bd41c162 100644 --- a/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts @@ -169,16 +169,6 @@ export class DeskFlowNewComponent extends AsyncHandler implements OnInit { public readonly form_value = this._booking_form.model; - public readonly has_title = computed( - () => !!this.form_value()?.title?.trim(), - ); - - public readonly has_item = computed( - () => - !!this.form_value()?.resources && - this.form_value()?.resources.length > 0, - ); - public readonly is_edit_mode = computed(() => !!this.form_value()?.id); /** Booking type selected in the details step */ public readonly active_form = signal('single'); diff --git a/apps/workplace/src/app/book/desk-flow.component.ts b/apps/workplace/src/app/book/desk-flow.component.ts index 0f845b6b41c..f64552795cc 100644 --- a/apps/workplace/src/app/book/desk-flow.component.ts +++ b/apps/workplace/src/app/book/desk-flow.component.ts @@ -53,10 +53,6 @@ export class NewDeskFlowComponent extends AsyncHandler implements OnInit { public readonly view = this._state.view; - public get last_success() { - return this._state.last_success; - } - public async ngOnInit() { await this._org.waitUntilInitialised(); await new Promise((resolve) => setTimeout(resolve, 300)); diff --git a/apps/workplace/src/app/book/locker-flow.component.ts b/apps/workplace/src/app/book/locker-flow.component.ts index 4d606dbfc17..fd7f0864ce4 100644 --- a/apps/workplace/src/app/book/locker-flow.component.ts +++ b/apps/workplace/src/app/book/locker-flow.component.ts @@ -114,10 +114,6 @@ export class BookLockerFlowComponent extends AsyncHandler implements OnInit { void this._loadTodaysBookings(); } - public get last_success() { - return this._state.last_success; - } - public ngOnInit() { this._state.loadForm('locker'); this._state.setOptions({ type: 'locker' }); diff --git a/apps/workplace/src/app/book/meeting-flow.component.ts b/apps/workplace/src/app/book/meeting-flow.component.ts index 8886e5595bc..c32e17e6321 100644 --- a/apps/workplace/src/app/book/meeting-flow.component.ts +++ b/apps/workplace/src/app/book/meeting-flow.component.ts @@ -44,8 +44,6 @@ export class BookMeetingFlowComponent extends AsyncHandler implements OnInit { private _space_pipe = inject(SpacePipe); public readonly view = this._state.view; - public readonly last_success = this._state.last_success; - public ngOnInit() { this._state.loadForm(); this.subscription( diff --git a/apps/workplace/src/app/book/parking-flow.component.ts b/apps/workplace/src/app/book/parking-flow.component.ts index c2eb43da2b8..329a1a7baaf 100644 --- a/apps/workplace/src/app/book/parking-flow.component.ts +++ b/apps/workplace/src/app/book/parking-flow.component.ts @@ -124,10 +124,6 @@ export class NewParkingFlowComponent extends AsyncHandler implements OnInit { return building?.display_name || building?.name || ''; } - public get last_success() { - return this._state.last_success; - } - public async ngOnInit() { this._parking.loadBookings(); await this._org.waitUntilInitialised(); diff --git a/apps/workplace/src/app/landing/landing-state.service.ts b/apps/workplace/src/app/landing/landing-state.service.ts index d2117307254..c42a476ada9 100644 --- a/apps/workplace/src/app/landing/landing-state.service.ts +++ b/apps/workplace/src/app/landing/landing-state.service.ts @@ -232,10 +232,6 @@ export class LandingStateService extends AsyncHandler { return this._schedule.startPolling(delay); } - public stopPollingUpcomingEvents() { - this._schedule.stopPolling(); - } - public refreshUpcomingEvents() { this._schedule.setDate(Date.now()); this._schedule.triggerPoll(); diff --git a/apps/workplace/src/tests/landing/landing-upcoming.component.spec.ts b/apps/workplace/src/tests/landing/landing-upcoming.component.spec.ts index 75b5522257d..90a2dad4062 100644 --- a/apps/workplace/src/tests/landing/landing-upcoming.component.spec.ts +++ b/apps/workplace/src/tests/landing/landing-upcoming.component.spec.ts @@ -40,7 +40,6 @@ describe('LandingUpcomingComponent', () => { upcoming_events, refreshUpcomingEvents: vi.fn(), pollUpcomingEvents: vi.fn(), - stopPollingUpcomingEvents: vi.fn(), }, }, { provide: SettingsService, useValue: { get: vi.fn() } }, From ebbef5815c9c6baae0768f0db16481756398b02f Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 17:07:52 +1100 Subject: [PATCH 03/21] fix(apps): fix small bugs found in the cleanup audit - Locker check-out messages use the correct translation keys. - Lockers and visitors reports no longer download as report+assets. - Parking request success shows times in the booking timezone. - The "no timezones" option shows when the filter matches nothing. - Visitor kiosk clears the stored location and stale orientation. - Survey and question removal close the dialog when the request fails. --- .../building-form.component.ts | 2 +- .../src/app/lockers/locker-state.service.ts | 4 +- .../region-manager/region-modal.component.ts | 2 +- .../reports/lockers/lockers-report.service.ts | 2 +- .../visitors/visitors-report.service.ts | 2 +- .../app/room-manager/room-modal.component.ts | 2 +- .../src/app/surveys/new-survey.service.ts | 37 +++++++++---------- .../src/app/surveys/survey.service.ts | 34 ++++++++--------- .../lockers/lockers-report.service.spec.ts | 2 +- .../visitors/visitors-report.service.spec.ts | 2 +- .../tests/surveys/new-survey.service.spec.ts | 15 +++++--- .../src/tests/surveys/survey.service.spec.ts | 17 +++++++++ .../src/app/bootstrap.component.ts | 5 ++- .../parking-request-success.component.ts | 20 ++++++++-- 14 files changed, 88 insertions(+), 58 deletions(-) diff --git a/apps/concierge/src/app/building-manager/building-form.component.ts b/apps/concierge/src/app/building-manager/building-form.component.ts index 2665151e1b1..9482119d78d 100644 --- a/apps/concierge/src/app/building-manager/building-form.component.ts +++ b/apps/concierge/src/app/building-manager/building-form.component.ts @@ -88,7 +88,7 @@ import { addZone, authority, updateZone } from '@placeos/ts-client'; @for (tz of filtered_timezones(); track tz) { {{ tz }} } - @if (!timezones.length) { + @if (!filtered_timezones().length) { {{ 'COMMON.TIMEZONE_EMPTY' | translate }} diff --git a/apps/concierge/src/app/lockers/locker-state.service.ts b/apps/concierge/src/app/lockers/locker-state.service.ts index a506ddf5089..a078c36ba14 100644 --- a/apps/concierge/src/app/lockers/locker-state.service.ts +++ b/apps/concierge/src/app/lockers/locker-state.service.ts @@ -841,7 +841,7 @@ export class LockerStateService extends AsyncHandler { i18n( state ? 'BOOKINGS.CHECK_IN_ERROR' - : 'BOOKINGS_CHECK_OUT_ERROR', + : 'BOOKINGS.CHECK_OUT_ERROR', ), ); throw status.error; @@ -850,7 +850,7 @@ export class LockerStateService extends AsyncHandler { i18n( state ? 'BOOKINGS.CHECK_IN_SUCCESS' - : 'BOOKINGS_CHECK_OUT_SUCCESS', + : 'BOOKINGS.CHECK_OUT_SUCCESS', ), ); } diff --git a/apps/concierge/src/app/region-manager/region-modal.component.ts b/apps/concierge/src/app/region-manager/region-modal.component.ts index 04af43050f1..d62c8828051 100644 --- a/apps/concierge/src/app/region-manager/region-modal.component.ts +++ b/apps/concierge/src/app/region-manager/region-modal.component.ts @@ -67,7 +67,7 @@ import { errorText } from '../ui/modal-actions'; @for (tz of filtered_timezones(); track tz) { {{ tz }} } - @if (!timezones.length) { + @if (!filtered_timezones().length) { {{ 'COMMON.TIMEZONE_EMPTY' | translate }} diff --git a/apps/concierge/src/app/reports/lockers/lockers-report.service.ts b/apps/concierge/src/app/reports/lockers/lockers-report.service.ts index 1cd514503ab..a900852789b 100644 --- a/apps/concierge/src/app/reports/lockers/lockers-report.service.ts +++ b/apps/concierge/src/app/reports/lockers/lockers-report.service.ts @@ -131,7 +131,7 @@ export class LockersReportService { 'yyyy-MM-dd', )}`; downloadFile( - `report+assets+${date}.tsv`, + `report+lockers+${date}.tsv`, jsonToCsv( bookings.map((booking) => { const b: any = booking.toJSON(); diff --git a/apps/concierge/src/app/reports/visitors/visitors-report.service.ts b/apps/concierge/src/app/reports/visitors/visitors-report.service.ts index 7066178682c..d4d52f20145 100644 --- a/apps/concierge/src/app/reports/visitors/visitors-report.service.ts +++ b/apps/concierge/src/app/reports/visitors/visitors-report.service.ts @@ -116,7 +116,7 @@ export class VisitorsReportService { 'yyyy-MM-dd', )}`; downloadFile( - `report+assets+${date}.tsv`, + `report+visitors+${date}.tsv`, jsonToCsv( bookings.map((booking) => { const b: any = booking.toJSON(); diff --git a/apps/concierge/src/app/room-manager/room-modal.component.ts b/apps/concierge/src/app/room-manager/room-modal.component.ts index 29cce7d6eaa..8108206af9a 100644 --- a/apps/concierge/src/app/room-manager/room-modal.component.ts +++ b/apps/concierge/src/app/room-manager/room-modal.component.ts @@ -394,7 +394,7 @@ import { SelectMapItemModalComponent } from '../ui/select-map-item-modal.compone {{ tz }} } - @if (!timezones().length) { + @if (!filtered_timezones().length) { {{ 'COMMON.TIMEZONE_EMPTY' | translate }} diff --git a/apps/concierge/src/app/surveys/new-survey.service.ts b/apps/concierge/src/app/surveys/new-survey.service.ts index d42e4bbeecd..b4e47f39cc5 100644 --- a/apps/concierge/src/app/surveys/new-survey.service.ts +++ b/apps/concierge/src/app/surveys/new-survey.service.ts @@ -8,13 +8,13 @@ import { } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { notifySuccess } from '@placeos/common'; -import { openConfirmModal } from '@placeos/components'; import { removeQuestion, showSurvey, Survey, SurveyQuestion, } from '@placeos/ts-client'; +import { confirmAction, errorText } from '../ui/modal-actions'; import { QuestionModalComponent } from './question-modal.component'; import { queryAllSurveyQuestions } from '../query-all-pages'; @@ -99,24 +99,23 @@ export class NewSurveyService { }); } - public async removeQuestion(question: SurveyQuestion, confirm = true) { - if (confirm) { - const result = await openConfirmModal( - { - title: 'Remove Survey Question', - content: - 'Are you sure you want to remove this survey question?', - icon: { content: 'warning' }, - }, - this._dialog, - ); - if (result.reason !== 'done') return; - result.loading('Removing survey question...'); - await removeQuestion(`${question.id}`); - result.close(); - } else { - await removeQuestion(`${question.id}`); - } + public async removeQuestion(question: SurveyQuestion) { + const removed = await confirmAction( + this._dialog, + { + title: 'Remove Survey Question', + content: + 'Are you sure you want to remove this survey question?', + icon: { content: 'warning' }, + }, + { + loading: 'Removing survey question...', + action: () => removeQuestion(`${question.id}`), + error: (e) => + `Failed to remove survey question. ${errorText(e)}`, + }, + ); + if (!removed) return; notifySuccess('Successfully removed survey question.'); this._change.update((v) => v + 1); } diff --git a/apps/concierge/src/app/surveys/survey.service.ts b/apps/concierge/src/app/surveys/survey.service.ts index cea3994e79f..65e7ee3d752 100644 --- a/apps/concierge/src/app/surveys/survey.service.ts +++ b/apps/concierge/src/app/surveys/survey.service.ts @@ -1,7 +1,6 @@ import { computed, inject, Injectable, signal } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { notifySuccess, OrganisationService } from '@placeos/common'; -import { openConfirmModal } from '@placeos/components'; import { queryAnswers, querySurveys, @@ -9,6 +8,7 @@ import { Survey, SurveyAnswer, } from '@placeos/ts-client'; +import { confirmAction, errorText } from '../ui/modal-actions'; /** Most answer requests that run at the same time */ const ANSWER_REQUEST_LIMIT = 6; @@ -86,23 +86,21 @@ export class SurveyService { this.building.set(id); } - public async removeSurvey(survey: Survey, confirm = true) { - if (confirm) { - const result = await openConfirmModal( - { - title: 'Remove Survey', - content: 'Are you sure you want to remove this survey?', - icon: { content: 'warning' }, - }, - this._dialog, - ); - if (result.reason !== 'done') return; - result.loading('Removing survey...'); - await removeSurvey(`${survey.id}`); - result.close(); - } else { - await removeSurvey(`${survey.id}`); - } + public async removeSurvey(survey: Survey) { + const removed = await confirmAction( + this._dialog, + { + title: 'Remove Survey', + content: 'Are you sure you want to remove this survey?', + icon: { content: 'warning' }, + }, + { + loading: 'Removing survey...', + action: () => removeSurvey(`${survey.id}`), + error: (e) => `Failed to remove survey. ${errorText(e)}`, + }, + ); + if (!removed) return; notifySuccess('Successfully removed survey.'); this._load(LoadType.SURVEYS); } diff --git a/apps/concierge/src/tests/reports/lockers/lockers-report.service.spec.ts b/apps/concierge/src/tests/reports/lockers/lockers-report.service.spec.ts index 62bf7f650d3..4219b4fd504 100644 --- a/apps/concierge/src/tests/reports/lockers/lockers-report.service.spec.ts +++ b/apps/concierge/src/tests/reports/lockers/lockers-report.service.spec.ts @@ -142,7 +142,7 @@ describe('LockersReportService', () => { spectator.service.setOptions({ start: day_1, end: day_1 }); await spectator.service.downloadReport(); - expect(downloads.filename).toMatch(/^report\+assets\+2026-04-06\.tsv$/); + expect(downloads.filename).toMatch(/^report\+lockers\+2026-04-06\.tsv$/); }); it('should count available lockers per zone', async () => { diff --git a/apps/concierge/src/tests/reports/visitors/visitors-report.service.spec.ts b/apps/concierge/src/tests/reports/visitors/visitors-report.service.spec.ts index 088325f1b8b..eae56d644c2 100644 --- a/apps/concierge/src/tests/reports/visitors/visitors-report.service.spec.ts +++ b/apps/concierge/src/tests/reports/visitors/visitors-report.service.spec.ts @@ -132,7 +132,7 @@ describe('VisitorsReportService', () => { await settle(); await spectator.service.downloadReport(); expect(downloads.filename).toMatch( - /^report\+assets\+2026-04-06-2026-04-07\.tsv$/, + /^report\+visitors\+2026-04-06-2026-04-07\.tsv$/, ); }); diff --git a/apps/concierge/src/tests/surveys/new-survey.service.spec.ts b/apps/concierge/src/tests/surveys/new-survey.service.spec.ts index 5b07077cd24..da7d15324f4 100644 --- a/apps/concierge/src/tests/surveys/new-survey.service.spec.ts +++ b/apps/concierge/src/tests/surveys/new-survey.service.spec.ts @@ -2,7 +2,7 @@ import { MatDialog } from '@angular/material/dialog'; import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest'; import { setNotifyOutlet } from '@placeos/common'; import { MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; +import { NEVER, of } from 'rxjs'; import * as ts_client from '@placeos/ts-client'; import { NewSurveyService } from '../../app/surveys/new-survey.service'; @@ -52,11 +52,14 @@ describe('NewSurveyService', () => { }); }); - it('should remove a question without confirmation when requested', async () => { - await spectator.service.removeQuestion( - new SurveyQuestion({ id: 5 }), - false, - ); + it('should remove a question after confirmation and notify', async () => { + dialog_open.mockReturnValue({ + componentInstance: { event: NEVER, loading: { set: vi.fn() } }, + afterClosed: () => of({ reason: 'done' }), + close: vi.fn(), + }); + + await spectator.service.removeQuestion(new SurveyQuestion({ id: 5 })); expect(ts_client.removeQuestion).toHaveBeenCalledWith('5'); expect(notify_open).toHaveBeenCalledWith( diff --git a/apps/concierge/src/tests/surveys/survey.service.spec.ts b/apps/concierge/src/tests/surveys/survey.service.spec.ts index 72191764a06..5d7af344a52 100644 --- a/apps/concierge/src/tests/surveys/survey.service.spec.ts +++ b/apps/concierge/src/tests/surveys/survey.service.spec.ts @@ -89,6 +89,23 @@ describe('SurveyService', () => { ); }); + it('should close the dialog and show the error when removal fails', async () => { + const ref = makeConfirmRef('done'); + dialog_open.mockReturnValue(ref); + vi.mocked(ts_client.removeSurvey).mockRejectedValueOnce( + new Error('Server down'), + ); + + await spectator.service.removeSurvey(new Survey({ id: 7 })); + + expect(ref.close).toHaveBeenCalled(); + expect(notify_open).toHaveBeenCalledWith( + expect.stringContaining('Server down'), + 'OK', + expect.objectContaining({ panelClass: ['error'] }), + ); + }); + it('should not remove a survey when confirmation is cancelled', async () => { dialog_open.mockReturnValue(makeConfirmRef('cancel')); diff --git a/apps/visitor-kiosk/src/app/bootstrap.component.ts b/apps/visitor-kiosk/src/app/bootstrap.component.ts index 4860764d2e2..5288133bf00 100644 --- a/apps/visitor-kiosk/src/app/bootstrap.component.ts +++ b/apps/visitor-kiosk/src/app/bootstrap.component.ts @@ -525,13 +525,13 @@ export class BootstrapComponent extends AsyncHandler implements OnInit { 'KIOSK.orientation', `${active_rotation.id}`, ); - } + } else localStorage.removeItem('KIOSK.orientation'); if (active_location) { localStorage.setItem( 'KIOSK.location', `${active_location.id}`, ); - } + } else localStorage.removeItem('KIOSK.location'); } this.navigateToStartupRoute(); } @@ -596,6 +596,7 @@ export class BootstrapComponent extends AsyncHandler implements OnInit { localStorage.removeItem('KIOSK.building'); localStorage.removeItem('KIOSK.level'); localStorage.removeItem('KIOSK.orientation'); + localStorage.removeItem('KIOSK.location'); } if (query_params.level) { const level = this._org.levelWithID([query_params.level]); diff --git a/apps/workplace/src/app/book/parking-request-flow/parking-request-success.component.ts b/apps/workplace/src/app/book/parking-request-flow/parking-request-success.component.ts index 6ebd77be168..999e6a2a575 100644 --- a/apps/workplace/src/app/book/parking-request-flow/parking-request-success.component.ts +++ b/apps/workplace/src/app/book/parking-request-flow/parking-request-success.component.ts @@ -3,7 +3,11 @@ import { Component, inject, OnInit, signal } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { RouterModule } from '@angular/router'; import { BookingFormService } from '@placeos/bookings'; -import { OrganisationService, SettingsService } from '@placeos/common'; +import { + getTimezoneOffsetString, + OrganisationService, + SettingsService, +} from '@placeos/common'; import { IconComponent, SafePipe, @@ -37,14 +41,15 @@ import { | translate : { date: - last_event.date | date: 'mediumDate', + last_event.date + | date: 'mediumDate' : tz, time: (last_event.date - | date: time_format) + + | date: time_format : tz) + ' - ' + (last_event.date + last_event.duration * 60 * 1000 - | date: time_format), + | date: time_format : tz), } }}

@@ -141,6 +146,13 @@ export class ParkingRequestSuccessComponent implements OnInit { return this._settings.time_format; } + /** Offset of the timezone that the booking was made in */ + public get tz() { + const event = this.last_event; + if (!event?.timezone) return ''; + return getTimezoneOffsetString(event.timezone, new Date(event.date)); + } + public get show_links() { return this._settings.get('app.parking.show_calendar_links'); } From 7a3cfa285403f75a776e934ade24d7e791411b93 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 17:07:53 +1100 Subject: [PATCH 04/21] refactor(libs): share the themed logo, announce directive and helpers - themedLogo() replaces about 20 copies of the dark/light logo choice. The logos now follow theme changes. - AnnounceDirective moves to libs/components. It waits for translations and does not announce after its element is destroyed. - mapWithLimit moves to libs/common. - Five apps share one ngsw-config.json. --- apps/booking-panel/project.json | 4 +- .../src/app/bootstrap.component.ts | 3 +- .../src/app/event-panel.component.ts | 24 ++--------- .../new-panel/panel-view-details.component.ts | 2 +- .../src/app/new-panel/panel-view.component.ts | 8 +++- .../app/overlays/booking-modal.component.ts | 7 ++- .../src/tests/event-panel.component.spec.ts | 11 ----- apps/concierge/ngsw-config.json | 43 ------------------- apps/concierge/project.json | 4 +- .../reports/assets/assets-report.component.ts | 24 ++--------- .../site-attendance-report.component.ts | 24 ++--------- .../catering/catering-report.component.ts | 17 ++------ .../contact-tracing-report.component.ts | 23 ++-------- .../reports/desks/report-desks.component.ts | 24 ++--------- .../lockers/lockers-report.component.ts | 24 ++--------- .../parking/parking-report.component.ts | 24 ++--------- .../reports/spaces/report-spaces.component.ts | 24 ++--------- .../app/reports/user/user-report.component.ts | 19 ++------ .../visitors/visitors-report.component.ts | 24 ++--------- .../src/app/surveys/survey.service.ts | 29 +++---------- .../src/app/ui/app-sidebar.component.ts | 22 ++-------- .../assets/assets-report.component.spec.ts | 27 +----------- .../site-attendance-report.component.spec.ts | 16 +------ .../contact-tracing-report.component.spec.ts | 25 +---------- .../desks/report-desks.component.spec.ts | 24 +---------- .../lockers/lockers-report.component.spec.ts | 24 +---------- .../parking/parking-report.component.spec.ts | 23 ---------- .../spaces/report-spaces.component.spec.ts | 20 --------- .../visitors-report.component.spec.ts | 20 --------- .../tests/ui/app-sidebar.component.spec.ts | 24 ----------- apps/control/ngsw-config.json | 43 ------------------- apps/control/project.json | 4 +- .../app/tabbed-view/tabbed-view.component.ts | 23 ++-------- .../src/app/topbar-header.component.ts | 20 ++------- .../src/app/ui/help-modal.component.ts | 15 ++----- .../src/tests/ui/help-modal.component.spec.ts | 34 +-------------- apps/map-kiosk/ngsw-config.json | 43 ------------------- apps/map-kiosk/project.json | 4 +- apps/map-kiosk/src/app/explore.component.ts | 14 ++---- apps/visitor-kiosk/ngsw-config.json | 43 ------------------- apps/visitor-kiosk/project.json | 4 +- apps/visitor-kiosk/src/app/app.component.ts | 2 +- .../src/app/bootstrap.component.ts | 7 ++- .../app/checkin/checkin-details.component.ts | 2 +- .../app/checkin/checkin-photo.component.ts | 3 +- .../checkin/checkin-preferences.component.ts | 7 ++- .../app/checkin/checkin-qr-scan.component.ts | 2 +- .../app/checkin/checkin-result.component.ts | 6 --- .../src/app/checkin/checkout.component.ts | 3 +- .../src/app/components/announce.directive.ts | 20 --------- .../app/components/take-photo.component.ts | 7 ++- .../src/app/visitor-registration.component.ts | 2 +- .../src/app/components/topbar.component.ts | 18 ++------ apps/workplace/src/app/landing/contacts.fn.ts | 24 +---------- .../team-schedule/team-schedule.service.ts | 7 +-- .../src/tests/landing/contacts.fn.spec.ts | 18 +------- libs/common/src/lib/general.ts | 22 ++++++++++ libs/common/src/lib/settings.service.ts | 34 +++++++++++---- libs/common/src/tests/general.spec.ts | 21 +++++++++ libs/common/src/tests/themed-logo.spec.ts | 43 +++++++++++++++++++ libs/components/src/index.ts | 1 + .../components/src/lib}/announce.directive.ts | 4 ++ .../src/lib/survey-outlet.component.ts | 16 ++----- .../src/tests/announce.directive.spec.ts | 16 ++++++- libs/users/src/lib/user-label.component.ts | 23 ++-------- .../src/tests/user-label.component.spec.ts | 31 +------------ nx.json | 3 +- .../booking-panel => shared}/ngsw-config.json | 2 +- tools/ngsw-config.test.js | 36 ++++++++++++---- 69 files changed, 285 insertions(+), 929 deletions(-) delete mode 100644 apps/concierge/ngsw-config.json delete mode 100644 apps/control/ngsw-config.json delete mode 100644 apps/map-kiosk/ngsw-config.json delete mode 100644 apps/visitor-kiosk/ngsw-config.json delete mode 100644 apps/visitor-kiosk/src/app/components/announce.directive.ts create mode 100644 libs/common/src/tests/themed-logo.spec.ts rename {apps/booking-panel/src/app => libs/components/src/lib}/announce.directive.ts (87%) rename {apps/booking-panel => libs/components}/src/tests/announce.directive.spec.ts (77%) rename {apps/booking-panel => shared}/ngsw-config.json (93%) diff --git a/apps/booking-panel/project.json b/apps/booking-panel/project.json index bffe4d5268a..27170dfa94f 100644 --- a/apps/booking-panel/project.json +++ b/apps/booking-panel/project.json @@ -60,7 +60,7 @@ "with": "apps/booking-panel/src/environments/settings.ts" } ], - "serviceWorker": "apps/booking-panel/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "production": { "fileReplacements": [ @@ -98,7 +98,7 @@ "maximumError": "10kb" } ], - "serviceWorker": "apps/booking-panel/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "mobile": { "fileReplacements": [ diff --git a/apps/booking-panel/src/app/bootstrap.component.ts b/apps/booking-panel/src/app/bootstrap.component.ts index 9757d1010f0..1a0bf5cf245 100644 --- a/apps/booking-panel/src/app/bootstrap.component.ts +++ b/apps/booking-panel/src/app/bootstrap.component.ts @@ -18,8 +18,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { Space } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { AnnounceDirective } from './announce.directive'; +import { AnnounceDirective, TranslatePipe } from '@placeos/components'; @Component({ selector: '[app-bootstrap]', diff --git a/apps/booking-panel/src/app/event-panel.component.ts b/apps/booking-panel/src/app/event-panel.component.ts index f82d5b7c713..2520f7e8288 100644 --- a/apps/booking-panel/src/app/event-panel.component.ts +++ b/apps/booking-panel/src/app/event-panel.component.ts @@ -6,6 +6,7 @@ import { AsyncHandler, OrganisationService, SettingsService, + themedLogo, } from '@placeos/common'; import { generateQRCode } from '@placeos/common/qr-code'; @@ -30,12 +31,7 @@ import { PanelStateService } from './panel-state.service';

{{ time() | date: time_format() }}

- Logo + Logo
- Logo + Logo

{{ time() | date: time_format() }}

@@ -228,14 +219,7 @@ export class EventPanelComponent extends AsyncHandler implements OnInit { '', ); - public readonly logo = computed( - () => ( - this._org.active_building(), - (this._settings.theme_signal() === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ), - ); + public readonly logo = themedLogo(); public get text_color() { return this._settings.get('app.text_color') || '#FFFFFF'; diff --git a/apps/booking-panel/src/app/new-panel/panel-view-details.component.ts b/apps/booking-panel/src/app/new-panel/panel-view-details.component.ts index 4834f7ccb11..75a9f2399e7 100644 --- a/apps/booking-panel/src/app/new-panel/panel-view-details.component.ts +++ b/apps/booking-panel/src/app/new-panel/panel-view-details.component.ts @@ -6,11 +6,11 @@ import { generateQRCode } from '@placeos/common/qr-code'; import { CommonModule } from '@angular/common'; import { + AnnounceDirective, AuthenticatedImageDirective, IconComponent, TranslatePipe, } from '@placeos/components'; -import { AnnounceDirective } from '../announce.directive'; import { PanelStateService } from '../panel-state.service'; import { endingSoon } from './helpers'; diff --git a/apps/booking-panel/src/app/new-panel/panel-view.component.ts b/apps/booking-panel/src/app/new-panel/panel-view.component.ts index 825f9449f4f..a839efd1b04 100644 --- a/apps/booking-panel/src/app/new-panel/panel-view.component.ts +++ b/apps/booking-panel/src/app/new-panel/panel-view.component.ts @@ -9,8 +9,12 @@ import { } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { AsyncHandler, RemoteLoggingService, VERSION } from '@placeos/common'; -import { IconComponent, SafePipe, TranslatePipe } from '@placeos/components'; -import { AnnounceDirective } from '../announce.directive'; +import { + AnnounceDirective, + IconComponent, + SafePipe, + TranslatePipe, +} from '@placeos/components'; import { PanelStateService, PanelTimelinePosition, diff --git a/apps/booking-panel/src/app/overlays/booking-modal.component.ts b/apps/booking-panel/src/app/overlays/booking-modal.component.ts index 5d9aae16556..b822a77e4b6 100644 --- a/apps/booking-panel/src/app/overlays/booking-modal.component.ts +++ b/apps/booking-panel/src/app/overlays/booking-modal.component.ts @@ -21,14 +21,17 @@ import { Space, User, } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; +import { + AnnounceDirective, + IconComponent, + TranslatePipe, +} from '@placeos/components'; import { DurationFieldComponent, TimeFieldComponent, UserSearchFieldComponent, } from '@placeos/form-fields'; import { getModule } from '@placeos/ts-client'; -import { AnnounceDirective } from '../announce.directive'; export interface BookingModalData extends HashMap { title?: string; diff --git a/apps/booking-panel/src/tests/event-panel.component.spec.ts b/apps/booking-panel/src/tests/event-panel.component.spec.ts index eedaada3403..8e3c2267b2f 100644 --- a/apps/booking-panel/src/tests/event-panel.component.spec.ts +++ b/apps/booking-panel/src/tests/event-panel.component.spec.ts @@ -16,7 +16,6 @@ describe('EventPanelComponent', () => { const space = signal(null); const settings = signal({}); const state_setting = vi.fn(); - const theme = signal('light'); const settings_get = vi.fn((_key: string): unknown => undefined); const createComponent = createRoutingFactory({ @@ -38,7 +37,6 @@ describe('EventPanelComponent', () => { MockProvider(SettingsService, { get: settings_get as SettingsService['get'], overrideCssVariable: vi.fn(), - theme_signal: theme, }), MockProvider(OrganisationService, { active_building: signal(null), @@ -54,7 +52,6 @@ describe('EventPanelComponent', () => { settings.set({}); state_setting.mockReset(); state_setting.mockReturnValue(undefined); - theme.set('light'); settings_get.mockImplementation(() => undefined); spectator = createComponent(); }); @@ -63,14 +60,6 @@ describe('EventPanelComponent', () => { expect(spectator.component).toBeTruthy(); }); - it('should use the dark logo in the dark theme', () => { - settings_get.mockImplementation((key: string) => - key === 'app.logo_dark' ? 'dark.svg' : 'light.svg', - ); - theme.set('dark'); - expect(spectator.component.logo()).toBe('dark.svg'); - }); - it('should display the space name in the header', () => { space.set({ display_name: 'Meeting Room 1' }); spectator.detectChanges(); diff --git a/apps/concierge/ngsw-config.json b/apps/concierge/ngsw-config.json deleted file mode 100644 index 3b65a2681c1..00000000000 --- a/apps/concierge/ngsw-config.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "$schema": "../../node_modules/@angular/service-worker/config/schema.json", - "index": "/index.html", - "assetGroups": [ - { - "name": "app", - "installMode": "prefetch", - "resources": { - "files": [ - "/favicon.ico", - "/index.html", - "/manifest.webmanifest", - "/*.css", - "/*.js" - ] - } - }, - { - "name": "assets", - "installMode": "lazy", - "updateMode": "prefetch", - "resources": { - "files": [ - "/assets/**", - "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" - ] - } - } - ], - "dataGroups": [ - { - "name": "s3", - "urls": [ - "https://*.amazonaws.com/**/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" - ], - "cacheConfig": { - "strategy": "performance", - "maxSize": 200, - "maxAge": "7d" - } - } - ] -} diff --git a/apps/concierge/project.json b/apps/concierge/project.json index 0d54a4e9e94..f706d0df967 100644 --- a/apps/concierge/project.json +++ b/apps/concierge/project.json @@ -65,7 +65,7 @@ "with": "apps/concierge/src/environments/settings.ts" } ], - "serviceWorker": "apps/concierge/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "production": { "fileReplacements": [ @@ -103,7 +103,7 @@ "maximumError": "16kb" } ], - "serviceWorker": "apps/concierge/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" } } }, diff --git a/apps/concierge/src/app/reports/assets/assets-report.component.ts b/apps/concierge/src/app/reports/assets/assets-report.component.ts index b7ac0b3a433..f1233eb3f36 100644 --- a/apps/concierge/src/app/reports/assets/assets-report.component.ts +++ b/apps/concierge/src/app/reports/assets/assets-report.component.ts @@ -1,11 +1,7 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { - AsyncHandler, - OrganisationService, - SettingsService, -} from '@placeos/common'; +import { AsyncHandler, themedLogo } from '@placeos/common'; import { AuthenticatedImageDirective, TranslatePipe, @@ -70,12 +66,7 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ >
- +

{{ 'APP.CONCIERGE.REPORTS_ASSETS_HEADER' | translate }} @@ -144,9 +135,7 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ }) export class AssetsReportComponent extends AsyncHandler implements OnInit { private _state = inject(AssetsReportService); - private _settings = inject(SettingsService); private _route = inject(ActivatedRoute); - private _org = inject(OrganisationService); public readonly printing = signal(false); public readonly metric_guide = METRIC_GUIDE; @@ -160,14 +149,7 @@ export class AssetsReportComponent extends AsyncHandler implements OnInit { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = computed(() => { - this._org.active_building(); - return ( - (this._settings.theme === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ); - }); + public readonly logo = themedLogo(); public ngOnInit() { this.subscription( diff --git a/apps/concierge/src/app/reports/attendance/site-attendance-report.component.ts b/apps/concierge/src/app/reports/attendance/site-attendance-report.component.ts index 1977fd0ca00..471c92e91a4 100644 --- a/apps/concierge/src/app/reports/attendance/site-attendance-report.component.ts +++ b/apps/concierge/src/app/reports/attendance/site-attendance-report.component.ts @@ -1,11 +1,7 @@ import { Component, computed, inject, signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { - AsyncHandler, - OrganisationService, - SettingsService, -} from '@placeos/common'; +import { AsyncHandler, themedLogo } from '@placeos/common'; import { AuthenticatedImageDirective, IconComponent, @@ -135,12 +131,7 @@ const PEOPLE_TABLE_METRIC_GUIDE: ReportMetricGuideItem[] = [ >
- +

{{ @@ -593,9 +584,7 @@ const PEOPLE_TABLE_METRIC_GUIDE: ReportMetricGuideItem[] = [ }) export class SiteAttendanceReportComponent extends AsyncHandler { private _state = inject(SiteAttendanceReportService); - private _settings = inject(SettingsService); private _route = inject(ActivatedRoute); - private _org = inject(OrganisationService); public readonly details = CARD_DETAILS; public readonly metric_guide = METRIC_GUIDE; @@ -619,14 +608,7 @@ export class SiteAttendanceReportComponent extends AsyncHandler { public readonly hasResource = (id: keyof typeof CARD_DETAILS) => this.report().cards.some((card) => card.id === id); - public readonly logo = computed(() => { - this._org.active_building(); - return ( - (this._settings.theme === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ); - }); + public readonly logo = themedLogo(); constructor() { super(); diff --git a/apps/concierge/src/app/reports/catering/catering-report.component.ts b/apps/concierge/src/app/reports/catering/catering-report.component.ts index 5b7da3163f4..4a875059b0b 100644 --- a/apps/concierge/src/app/reports/catering/catering-report.component.ts +++ b/apps/concierge/src/app/reports/catering/catering-report.component.ts @@ -5,6 +5,7 @@ import { AsyncHandler, OrganisationService, SettingsService, + themedLogo, } from '@placeos/common'; import { AuthenticatedImageDirective, @@ -66,12 +67,7 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [
- +

{{ @@ -150,14 +146,7 @@ export class CateringReportComponent extends AsyncHandler { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = computed(() => { - this._org.active_building(); - return ( - (this._settings.theme === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ); - }); + public readonly logo = themedLogo(); public get using_bookings() { return this._settings.get('app.catering.use_bookings') == true; diff --git a/apps/concierge/src/app/reports/contact-tracing/contact-tracing-report.component.ts b/apps/concierge/src/app/reports/contact-tracing/contact-tracing-report.component.ts index a40104bd4a1..e11a705b6ec 100644 --- a/apps/concierge/src/app/reports/contact-tracing/contact-tracing-report.component.ts +++ b/apps/concierge/src/app/reports/contact-tracing/contact-tracing-report.component.ts @@ -1,11 +1,7 @@ import { CommonModule } from '@angular/common'; import { Component, computed, inject, signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { - formatDuration, - OrganisationService, - SettingsService, -} from '@placeos/common'; +import { formatDuration, SettingsService, themedLogo } from '@placeos/common'; import { AuthenticatedImageDirective, SimpleTableComponent, @@ -79,12 +75,7 @@ const TABLE_METRIC_GUIDE: ReportMetricGuideItem[] = [
- +

{{ @@ -213,7 +204,6 @@ const TABLE_METRIC_GUIDE: ReportMetricGuideItem[] = [ export class ContactTracingReportComponent { private _state = inject(ContactTracingStateService); private _settings = inject(SettingsService); - private _org = inject(OrganisationService); public readonly printing = signal(false); public readonly metric_guide = METRIC_GUIDE; @@ -233,12 +223,5 @@ export class ContactTracingReportComponent { return this._settings.time_format; } - public readonly logo = computed(() => { - this._org.active_building(); - return ( - (this._settings.theme === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ); - }); + public readonly logo = themedLogo(); } diff --git a/apps/concierge/src/app/reports/desks/report-desks.component.ts b/apps/concierge/src/app/reports/desks/report-desks.component.ts index babfc726c46..95faaa982d5 100644 --- a/apps/concierge/src/app/reports/desks/report-desks.component.ts +++ b/apps/concierge/src/app/reports/desks/report-desks.component.ts @@ -1,11 +1,7 @@ import { Component, computed, inject, signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { - AsyncHandler, - OrganisationService, - SettingsService, -} from '@placeos/common'; +import { AsyncHandler, themedLogo } from '@placeos/common'; import { AuthenticatedImageDirective, TranslatePipe, @@ -63,12 +59,7 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ >
- +

{{ 'APP.CONCIERGE.REPORTS_DESKS_HEADER' | translate }} @@ -169,9 +160,7 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ }) export class ReportDesksComponent extends AsyncHandler { private _state = inject(ReportsStateService); - private _settings = inject(SettingsService); private _route = inject(ActivatedRoute); - private _org = inject(OrganisationService); private readonly _stats = this._state.stats; public readonly printing = signal(false); @@ -194,14 +183,7 @@ export class ReportDesksComponent extends AsyncHandler { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = computed(() => { - this._org.active_building(); - return ( - (this._settings.theme === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ); - }); + public readonly logo = themedLogo(); constructor() { super(); diff --git a/apps/concierge/src/app/reports/lockers/lockers-report.component.ts b/apps/concierge/src/app/reports/lockers/lockers-report.component.ts index aaab81fe28f..434e2e378c7 100644 --- a/apps/concierge/src/app/reports/lockers/lockers-report.component.ts +++ b/apps/concierge/src/app/reports/lockers/lockers-report.component.ts @@ -1,11 +1,7 @@ import { Component, computed, inject, signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { - AsyncHandler, - OrganisationService, - SettingsService, -} from '@placeos/common'; +import { AsyncHandler, themedLogo } from '@placeos/common'; import { AuthenticatedImageDirective, TranslatePipe, @@ -64,12 +60,7 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ >
- +

{{ 'APP.CONCIERGE.REPORTS_LOCKERS_HEADER' | translate }} @@ -132,9 +123,7 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ }) export class LockersReportComponent extends AsyncHandler { private _state = inject(LockersReportService); - private _settings = inject(SettingsService); private _route = inject(ActivatedRoute); - private _org = inject(OrganisationService); private readonly _bookings = this._state.bookings; public readonly printing = signal(false); public readonly metric_guide = METRIC_GUIDE; @@ -144,14 +133,7 @@ export class LockersReportComponent extends AsyncHandler { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = computed(() => { - this._org.active_building(); - return ( - (this._settings.theme === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ); - }); + public readonly logo = themedLogo(); constructor() { super(); diff --git a/apps/concierge/src/app/reports/parking/parking-report.component.ts b/apps/concierge/src/app/reports/parking/parking-report.component.ts index 438f5b25d1c..b931c797c84 100644 --- a/apps/concierge/src/app/reports/parking/parking-report.component.ts +++ b/apps/concierge/src/app/reports/parking/parking-report.component.ts @@ -1,11 +1,7 @@ import { Component, computed, inject, signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { - AsyncHandler, - OrganisationService, - SettingsService, -} from '@placeos/common'; +import { AsyncHandler, themedLogo } from '@placeos/common'; import { AuthenticatedImageDirective, TranslatePipe, @@ -71,12 +67,7 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ >
- +

{{ 'APP.CONCIERGE.REPORTS_PARKING_HEADER' | translate }} @@ -143,9 +134,7 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ }) export class ParkingReportComponent extends AsyncHandler { private _state = inject(ParkingReportService); - private _settings = inject(SettingsService); private _route = inject(ActivatedRoute); - private _org = inject(OrganisationService); private readonly _bookings = this._state.bookings; public readonly printing = signal(false); public readonly metric_guide = METRIC_GUIDE; @@ -155,14 +144,7 @@ export class ParkingReportComponent extends AsyncHandler { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = computed(() => { - this._org.active_building(); - return ( - (this._settings.theme === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ); - }); + public readonly logo = themedLogo(); constructor() { super(); diff --git a/apps/concierge/src/app/reports/spaces/report-spaces.component.ts b/apps/concierge/src/app/reports/spaces/report-spaces.component.ts index 273c9091aa0..5b7f37c00e6 100644 --- a/apps/concierge/src/app/reports/spaces/report-spaces.component.ts +++ b/apps/concierge/src/app/reports/spaces/report-spaces.component.ts @@ -1,11 +1,7 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { - AsyncHandler, - OrganisationService, - SettingsService, -} from '@placeos/common'; +import { AsyncHandler, themedLogo } from '@placeos/common'; import { AuthenticatedImageDirective, TranslatePipe, @@ -65,12 +61,7 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ >
- +

{{ 'APP.CONCIERGE.REPORTS_ROOMS_HEADER' | translate }} @@ -139,9 +130,7 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ }) export class ReportSpacesComponent extends AsyncHandler implements OnInit { private _state = inject(ReportsStateService); - private _settings = inject(SettingsService); private _route = inject(ActivatedRoute); - private _org = inject(OrganisationService); public readonly printing = signal(false); public readonly metric_guide = METRIC_GUIDE; @@ -155,14 +144,7 @@ export class ReportSpacesComponent extends AsyncHandler implements OnInit { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = computed(() => { - this._org.active_building(); - return ( - (this._settings.theme === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ); - }); + public readonly logo = themedLogo(); public ngOnInit() { this._state.setOptions({ type: 'events' }); diff --git a/apps/concierge/src/app/reports/user/user-report.component.ts b/apps/concierge/src/app/reports/user/user-report.component.ts index d2a5cefc2d0..d7882768343 100644 --- a/apps/concierge/src/app/reports/user/user-report.component.ts +++ b/apps/concierge/src/app/reports/user/user-report.component.ts @@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { OrganisationService, SettingsService } from '@placeos/common'; +import { SettingsService, themedLogo } from '@placeos/common'; import { AuthenticatedImageDirective, DurationPipe, @@ -74,12 +74,7 @@ import { UserReportOptions, UserReportService } from './user-report.service'; class="relative h-1/2 w-full flex-1 overflow-auto print:h-auto print:overflow-visible" >
- +

@@ -221,7 +216,6 @@ import { UserReportOptions, UserReportService } from './user-report.service'; export class UserReportComponent { private _state = inject(UserReportService); private _settings = inject(SettingsService); - private _org = inject(OrganisationService); public readonly printing = signal(false); public readonly options = this._state.options; @@ -242,14 +236,7 @@ export class UserReportComponent { ]; }); - public readonly logo = computed(() => { - this._org.active_building(); - return ( - (this._settings.theme === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ); - }); + public readonly logo = themedLogo(); public readonly setOptions = (options: Partial) => this._state.setOptions(options); diff --git a/apps/concierge/src/app/reports/visitors/visitors-report.component.ts b/apps/concierge/src/app/reports/visitors/visitors-report.component.ts index 60188e036f4..38d130783c2 100644 --- a/apps/concierge/src/app/reports/visitors/visitors-report.component.ts +++ b/apps/concierge/src/app/reports/visitors/visitors-report.component.ts @@ -1,11 +1,7 @@ import { Component, OnInit, computed, inject, signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { - AsyncHandler, - OrganisationService, - SettingsService, -} from '@placeos/common'; +import { AsyncHandler, themedLogo } from '@placeos/common'; import { AuthenticatedImageDirective, TranslatePipe, @@ -62,12 +58,7 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ >
- +

{{ @@ -128,9 +119,7 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ }) export class VisitorsReportComponent extends AsyncHandler implements OnInit { private _state = inject(VisitorsReportService); - private _settings = inject(SettingsService); private _route = inject(ActivatedRoute); - private _org = inject(OrganisationService); public readonly printing = signal(false); public readonly metric_guide = METRIC_GUIDE; @@ -143,14 +132,7 @@ export class VisitorsReportComponent extends AsyncHandler implements OnInit { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = computed(() => { - this._org.active_building(); - return ( - (this._settings.theme === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ); - }); + public readonly logo = themedLogo(); public ngOnInit() { this.subscription( diff --git a/apps/concierge/src/app/surveys/survey.service.ts b/apps/concierge/src/app/surveys/survey.service.ts index 65e7ee3d752..46e1f53f1c3 100644 --- a/apps/concierge/src/app/surveys/survey.service.ts +++ b/apps/concierge/src/app/surveys/survey.service.ts @@ -1,6 +1,10 @@ import { computed, inject, Injectable, signal } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; -import { notifySuccess, OrganisationService } from '@placeos/common'; +import { + mapWithLimit, + notifySuccess, + OrganisationService, +} from '@placeos/common'; import { queryAnswers, querySurveys, @@ -13,29 +17,6 @@ import { confirmAction, errorText } from '../ui/modal-actions'; /** Most answer requests that run at the same time */ const ANSWER_REQUEST_LIMIT = 6; -/** - * Run `fn` for each item with at most `limit` calls in progress. - * Results keep the order of the items. - */ -async function mapWithLimit( - items: readonly T[], - limit: number, - fn: (item: T) => Promise, -): Promise { - const results = new Array(items.length); - let next_index = 0; - const worker = async () => { - while (next_index < items.length) { - const index = next_index++; - results[index] = await fn(items[index]); - } - }; - await Promise.all( - Array.from({ length: Math.min(limit, items.length) }, worker), - ); - return results; -} - enum LoadType { SURVEYS = 'surveys', ANSWERS = 'answers', diff --git a/apps/concierge/src/app/ui/app-sidebar.component.ts b/apps/concierge/src/app/ui/app-sidebar.component.ts index 40bee23473c..fabe55494e2 100644 --- a/apps/concierge/src/app/ui/app-sidebar.component.ts +++ b/apps/concierge/src/app/ui/app-sidebar.component.ts @@ -13,6 +13,7 @@ import { AsyncHandler, OrganisationService, SettingsService, + themedLogo, user_group_names, userSignal, } from '@placeos/common'; @@ -343,7 +344,7 @@ const SIDEBAR_GROUPS: readonly SidebarGroup[] = [ @@ -527,17 +528,6 @@ export class ApplicationSidebarComponent private _element_ref = inject>(ElementRef); private _shortcuts = inject(KeyboardShortcutsService); - private readonly _logo_dark = this._settings.signal( - 'app.logo_dark', - {}, - true, - ); - private readonly _logo_light = this._settings.signal( - 'app.logo_light', - {}, - true, - ); - public readonly collapsed = collapsed_groups; /** Whether the sidebar shows icons only */ public readonly rail = rail_mode.asReadonly(); @@ -548,13 +538,7 @@ export class ApplicationSidebarComponent ); /** Source of the logo for the current theme */ - public readonly logo_src = computed(() => { - const logo: string | { src?: string } = - (this._settings.theme_signal() === 'dark' - ? this._logo_dark() - : this._logo_light()) || {}; - return typeof logo === 'string' ? logo : logo?.src || ''; - }); + public readonly logo = themedLogo(); private readonly _is_admin = computed(() => { const groups = user_group_names(); diff --git a/apps/concierge/src/tests/reports/assets/assets-report.component.spec.ts b/apps/concierge/src/tests/reports/assets/assets-report.component.spec.ts index 9ad0e0d5f83..ccec83ab5de 100644 --- a/apps/concierge/src/tests/reports/assets/assets-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/assets/assets-report.component.spec.ts @@ -2,7 +2,6 @@ import { signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { OrganisationService, SettingsService } from '@placeos/common'; import { AuthenticatedImageDirective } from '@placeos/components'; import { BehaviorSubject } from 'rxjs'; @@ -15,14 +14,13 @@ import { AssetsReportComponent } from 'apps/concierge/src/app/reports/assets/ass import { AssetsReportService } from 'apps/concierge/src/app/reports/assets/assets-report.service'; import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; -import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; +import { MockComponent, MockDirective } from 'ng-mocks'; describe('AssetsReportComponent', () => { let spectator: Spectator; let query_params: BehaviorSubject; let stats: ReturnType>; let loading: ReturnType>; - let active_building: ReturnType>; let set_options: unknown; let generate_report: unknown; let download_report: unknown; @@ -40,7 +38,6 @@ describe('AssetsReportComponent', () => { MockDirective(AuthenticatedImageDirective), ], imports: [MatProgressSpinnerModule], - providers: [MockProvider(SettingsService, {})], }); beforeEach(() => { @@ -53,7 +50,6 @@ describe('AssetsReportComponent', () => { ); stats = signal({ total_booked_items: 0 }); loading = signal(false); - active_building = signal({ id: 'building-1' }); set_options = vi.fn(); generate_report = vi.fn(); download_report = vi.fn(); @@ -69,23 +65,6 @@ describe('AssetsReportComponent', () => { downloadReport: download_report, }, }, - { - provide: SettingsService, - useValue: { - theme: 'light', - get: vi.fn((key: string) => { - if (key === 'app.logo_light') - return { src: 'light-logo.svg' }; - if (key === 'app.logo_dark') - return { src: 'dark-logo.svg' }; - return undefined; - }), - }, - }, - { - provide: OrganisationService, - useValue: { active_building }, - }, { provide: ActivatedRoute, useValue: { queryParamMap: query_params }, @@ -121,8 +100,4 @@ describe('AssetsReportComponent', () => { expect(generate_report).toHaveBeenCalled(); expect(download_report).toHaveBeenCalled(); }); - - it('should select the light logo by default', () => { - expect(spectator.component.logo()).toEqual({ src: 'light-logo.svg' }); - }); }); diff --git a/apps/concierge/src/tests/reports/attendance/site-attendance-report.component.spec.ts b/apps/concierge/src/tests/reports/attendance/site-attendance-report.component.spec.ts index 89631bbc0ec..db0f4ebdb74 100644 --- a/apps/concierge/src/tests/reports/attendance/site-attendance-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/attendance/site-attendance-report.component.spec.ts @@ -3,7 +3,7 @@ import { BehaviorSubject } from 'rxjs'; import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { OrganisationService, SettingsService } from '@placeos/common'; +import { OrganisationService } from '@placeos/common'; import { SiteAttendanceReportComponent } from 'apps/concierge/src/app/reports/attendance/site-attendance-report.component'; import { EMPTY_REPORT, @@ -30,16 +30,6 @@ describe('SiteAttendanceReportComponent', () => { downloadReport: vi.fn(), generateReport: vi.fn(), }), - MockProvider(SettingsService, { - theme: 'light', - get: vi.fn((key: string) => { - if (key === 'app.logo_light') - return { src: 'light-logo.svg' }; - if (key === 'app.logo_dark') - return { src: 'dark-logo.svg' }; - return undefined; - }), - } as any), MockProvider(OrganisationService, { initialised: signal(true), waitUntilInitialised: () => Promise.resolve(), @@ -164,10 +154,6 @@ describe('SiteAttendanceReportComponent', () => { ).toHaveBeenCalled(); }); - it('should select the light logo by default', () => { - expect(spectator.component.logo()).toEqual({ src: 'light-logo.svg' }); - }); - it('should clear zones when query params do not include them', () => { query_params.next(convertToParamMap({ start: '100', end: '200' })); spectator.detectChanges(); diff --git a/apps/concierge/src/tests/reports/contact-tracing/contact-tracing-report.component.spec.ts b/apps/concierge/src/tests/reports/contact-tracing/contact-tracing-report.component.spec.ts index 5a10ddd58e7..a88e7e67892 100644 --- a/apps/concierge/src/tests/reports/contact-tracing/contact-tracing-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/contact-tracing/contact-tracing-report.component.spec.ts @@ -3,7 +3,7 @@ import { signal, WritableSignal } from '@angular/core'; import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { OrganisationService, SettingsService } from '@placeos/common'; +import { SettingsService } from '@placeos/common'; import { AuthenticatedImageDirective, SimpleTableComponent, @@ -21,7 +21,6 @@ describe('ContactTracingReportComponent', () => { let options: WritableSignal; let events: WritableSignal; let settings_map: Record; - let theme: string; const createComponent = createComponentFactory({ component: ContactTracingReportComponent, declarations: [ @@ -41,16 +40,9 @@ describe('ContactTracingReportComponent', () => { provide: SettingsService, useValue: { get: (key: string) => settings_map[key], - get theme() { - return theme; - }, time_format: 'h:mm a', }, }, - { - provide: OrganisationService, - useValue: { active_building: signal({ id: 'bld-1' }) }, - }, ], imports: [MatProgressSpinnerModule], }); @@ -59,11 +51,7 @@ describe('ContactTracingReportComponent', () => { loading = signal(''); options = signal({ start: 0, end: 0 }); events = signal([]); - theme = 'light'; - settings_map = { - 'app.logo_light': { src: 'light.png' }, - 'app.logo_dark': { src: 'dark.png' }, - }; + settings_map = {}; spectator = createComponent(); }); @@ -89,15 +77,6 @@ describe('ContactTracingReportComponent', () => { expect(spectator.query('simple-table')).toBeFalsy(); }); - it('should pick the logo matching the active theme', () => { - expect(spectator.component.logo()).toEqual({ src: 'light.png' }); - theme = 'dark'; - (spectator.inject(OrganisationService).active_building as any).set({ - id: 'bld-2', - }); - expect(spectator.component.logo()).toEqual({ src: 'dark.png' }); - }); - it('should format durations as hours and minutes', () => { expect(spectator.component.formatDuration(90)).toContain('1'); expect(spectator.component.formatDuration(0)).toBeDefined(); diff --git a/apps/concierge/src/tests/reports/desks/report-desks.component.spec.ts b/apps/concierge/src/tests/reports/desks/report-desks.component.spec.ts index baf3d1b4d82..d263f6a60ed 100644 --- a/apps/concierge/src/tests/reports/desks/report-desks.component.spec.ts +++ b/apps/concierge/src/tests/reports/desks/report-desks.component.spec.ts @@ -2,7 +2,6 @@ import { signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { OrganisationService, SettingsService } from '@placeos/common'; import { AuthenticatedImageDirective } from '@placeos/components'; import { BehaviorSubject } from 'rxjs'; @@ -13,14 +12,13 @@ import { ReportDesksComponent } from 'apps/concierge/src/app/reports/desks/repor import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; import { ReportsStateService } from 'apps/concierge/src/app/reports/reports-state.service'; -import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; +import { MockComponent, MockDirective } from 'ng-mocks'; describe('ReportDesksComponent', () => { let spectator: Spectator; let query_params: BehaviorSubject; let stats: ReturnType>; let loading: ReturnType>; - let active_building: ReturnType>; let set_options: unknown; let generate_report: unknown; let download_report: unknown; @@ -36,7 +34,6 @@ describe('ReportDesksComponent', () => { MockDirective(AuthenticatedImageDirective), ], imports: [MatProgressSpinnerModule], - providers: [MockProvider(SettingsService, {})], }); beforeEach(() => { @@ -55,7 +52,6 @@ describe('ReportDesksComponent', () => { utilisation: 0.25, }); loading = signal(''); - active_building = signal({ id: 'building-1' }); set_options = vi.fn(); generate_report = vi.fn(); download_report = vi.fn(); @@ -71,20 +67,6 @@ describe('ReportDesksComponent', () => { downloadReport: download_report, }, }, - { - provide: SettingsService, - useValue: { - theme: 'light', - get: vi.fn((key: string) => { - if (key === 'app.logo_light') - return { src: 'light-logo.svg' }; - if (key === 'app.logo_dark') - return { src: 'dark-logo.svg' }; - return undefined; - }), - }, - }, - { provide: OrganisationService, useValue: { active_building } }, { provide: ActivatedRoute, useValue: { queryParamMap: query_params }, @@ -121,8 +103,4 @@ describe('ReportDesksComponent', () => { expect(generate_report).toHaveBeenCalled(); expect(download_report).toHaveBeenCalled(); }); - - it('should select the light logo by default', () => { - expect(spectator.component.logo()).toEqual({ src: 'light-logo.svg' }); - }); }); diff --git a/apps/concierge/src/tests/reports/lockers/lockers-report.component.spec.ts b/apps/concierge/src/tests/reports/lockers/lockers-report.component.spec.ts index 66597900251..5977bfa8334 100644 --- a/apps/concierge/src/tests/reports/lockers/lockers-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/lockers/lockers-report.component.spec.ts @@ -2,7 +2,6 @@ import { signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { OrganisationService, SettingsService } from '@placeos/common'; import { AuthenticatedImageDirective } from '@placeos/components'; import { BehaviorSubject } from 'rxjs'; @@ -14,14 +13,13 @@ import { LockersReportComponent } from 'apps/concierge/src/app/reports/lockers/l import { LockersReportService } from 'apps/concierge/src/app/reports/lockers/lockers-report.service'; import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; -import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; +import { MockComponent, MockDirective } from 'ng-mocks'; describe('LockersReportComponent', () => { let spectator: Spectator; let query_params: BehaviorSubject; let bookings: ReturnType>; let loading: ReturnType>; - let active_building: ReturnType>; let set_options: unknown; let generate_report: unknown; let download_report: unknown; @@ -38,7 +36,6 @@ describe('LockersReportComponent', () => { MockDirective(AuthenticatedImageDirective), ], imports: [MatProgressSpinnerModule], - providers: [MockProvider(SettingsService, {})], }); beforeEach(() => { @@ -51,7 +48,6 @@ describe('LockersReportComponent', () => { ); bookings = signal([]); loading = signal(''); - active_building = signal({ id: 'building-1' }); set_options = vi.fn(); generate_report = vi.fn(); download_report = vi.fn(); @@ -67,20 +63,6 @@ describe('LockersReportComponent', () => { downloadReport: download_report, }, }, - { - provide: SettingsService, - useValue: { - theme: 'dark', - get: vi.fn((key: string) => { - if (key === 'app.logo_light') - return { src: 'light-logo.svg' }; - if (key === 'app.logo_dark') - return { src: 'dark-logo.svg' }; - return undefined; - }), - }, - }, - { provide: OrganisationService, useValue: { active_building } }, { provide: ActivatedRoute, useValue: { queryParamMap: query_params }, @@ -114,8 +96,4 @@ describe('LockersReportComponent', () => { expect(generate_report).toHaveBeenCalled(); expect(download_report).toHaveBeenCalled(); }); - - it('should select the dark logo when the theme is dark', () => { - expect(spectator.component.logo()).toEqual({ src: 'dark-logo.svg' }); - }); }); diff --git a/apps/concierge/src/tests/reports/parking/parking-report.component.spec.ts b/apps/concierge/src/tests/reports/parking/parking-report.component.spec.ts index d57b2612c80..d9607c6703f 100644 --- a/apps/concierge/src/tests/reports/parking/parking-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/parking/parking-report.component.spec.ts @@ -5,7 +5,6 @@ import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; -import { OrganisationService, SettingsService } from '@placeos/common'; import { AuthenticatedImageDirective } from '@placeos/components'; import { ParkingReportChartsComponent } from 'apps/concierge/src/app/reports/parking/parking-report-charts.component'; import { ParkingReportDailyUsageComponent } from 'apps/concierge/src/app/reports/parking/parking-report-daily-usage.component'; @@ -44,13 +43,6 @@ describe('ParkingReportComponent', () => { generateReport: vi.fn(), downloadReport: vi.fn(), }), - MockProvider(SettingsService, { - theme: 'light', - get: vi.fn(), - }), - MockProvider(OrganisationService, { - active_building: signal({ id: 'building-1' }), - } as any), MockProvider(ActivatedRoute, { queryParamMap: new BehaviorSubject(convertToParamMap({})), }), @@ -81,17 +73,6 @@ describe('ParkingReportComponent', () => { downloadReport: download, }, }, - { - provide: SettingsService, - useValue: { - theme: 'light', - get: vi.fn((key: string) => - key === 'app.logo_light' - ? { src: 'light.svg' } - : undefined, - ), - }, - }, { provide: ActivatedRoute, useValue: { queryParamMap: query_params }, @@ -118,10 +99,6 @@ describe('ParkingReportComponent', () => { expect(spectator.component.total_count()).toBe(2); }); - it('should select the light logo by default', () => { - expect(spectator.component.logo()).toEqual({ src: 'light.svg' }); - }); - it('should delegate generate and download to the report service', () => { spectator.component.generateReport(); spectator.component.downloadReport(); diff --git a/apps/concierge/src/tests/reports/spaces/report-spaces.component.spec.ts b/apps/concierge/src/tests/reports/spaces/report-spaces.component.spec.ts index cb72763ade5..8164c2b5594 100644 --- a/apps/concierge/src/tests/reports/spaces/report-spaces.component.spec.ts +++ b/apps/concierge/src/tests/reports/spaces/report-spaces.component.spec.ts @@ -5,7 +5,6 @@ import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; -import { OrganisationService, SettingsService } from '@placeos/common'; import { AuthenticatedImageDirective } from '@placeos/components'; import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; @@ -47,10 +46,6 @@ describe('ReportSpacesComponent', () => { generateReport: vi.fn(), downloadReport: vi.fn(), }), - MockProvider(SettingsService, { theme: 'light', get: vi.fn() }), - MockProvider(OrganisationService, { - active_building: signal({ id: 'building-1' }), - } as any), MockProvider(ActivatedRoute, { queryParamMap: new BehaviorSubject(convertToParamMap({})), }), @@ -82,17 +77,6 @@ describe('ReportSpacesComponent', () => { downloadReport: download, }, }, - { - provide: SettingsService, - useValue: { - theme: 'light', - get: vi.fn((key: string) => - key === 'app.logo_light' - ? { src: 'light.svg' } - : undefined, - ), - }, - }, { provide: ActivatedRoute, useValue: { queryParamMap: query_params }, @@ -131,10 +115,6 @@ describe('ReportSpacesComponent', () => { expect(spectator.component.total_count()).toBe(5); }); - it('should select the light logo by default', () => { - expect(spectator.component.logo()).toEqual({ src: 'light.svg' }); - }); - it('should delegate generate and download to the report state', () => { spectator.component.generateReport(); spectator.component.downloadReport(); diff --git a/apps/concierge/src/tests/reports/visitors/visitors-report.component.spec.ts b/apps/concierge/src/tests/reports/visitors/visitors-report.component.spec.ts index 824ead72a78..4bd73e581c2 100644 --- a/apps/concierge/src/tests/reports/visitors/visitors-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/visitors/visitors-report.component.spec.ts @@ -5,7 +5,6 @@ import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; -import { OrganisationService, SettingsService } from '@placeos/common'; import { AuthenticatedImageDirective } from '@placeos/components'; import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; @@ -43,10 +42,6 @@ describe('VisitorsReportComponent', () => { generateReport: vi.fn(), downloadReport: vi.fn(), }), - MockProvider(SettingsService, { theme: 'light', get: vi.fn() }), - MockProvider(OrganisationService, { - active_building: signal({ id: 'building-1' }), - } as any), MockProvider(ActivatedRoute, { queryParamMap: new BehaviorSubject(convertToParamMap({})), }), @@ -78,17 +73,6 @@ describe('VisitorsReportComponent', () => { downloadReport: download, }, }, - { - provide: SettingsService, - useValue: { - theme: 'light', - get: vi.fn((key: string) => - key === 'app.logo_light' - ? { src: 'light.svg' } - : undefined, - ), - }, - }, { provide: ActivatedRoute, useValue: { queryParamMap: query_params }, @@ -118,10 +102,6 @@ describe('VisitorsReportComponent', () => { expect(spectator.component.has_data()).toBe(true); }); - it('should select the light logo by default', () => { - expect(spectator.component.logo()).toEqual({ src: 'light.svg' }); - }); - it('should delegate generate and download to the report service', () => { spectator.component.generateReport(); spectator.component.downloadReport(); diff --git a/apps/concierge/src/tests/ui/app-sidebar.component.spec.ts b/apps/concierge/src/tests/ui/app-sidebar.component.spec.ts index f64ceef2613..9c0fffd3b62 100644 --- a/apps/concierge/src/tests/ui/app-sidebar.component.spec.ts +++ b/apps/concierge/src/tests/ui/app-sidebar.component.spec.ts @@ -23,9 +23,6 @@ class BlankComponent {} describe('ApplicationSidebarComponent', () => { let spectator: Spectator; let app_settings: Record; - const theme = signal<'light' | 'dark'>('light'); - const logo_dark = signal('dark-logo.png'); - const logo_light = signal('light-logo.png'); const createComponent = createComponentFactory({ component: ApplicationSidebarComponent, shallow: true, @@ -41,11 +38,6 @@ describe('ApplicationSidebarComponent', () => { MockProvider(SettingsService, { get: ((name: string) => app_settings[name]) as SettingsService['get'], - theme_signal: theme, - signal: ((name: string) => - name === 'app.logo_dark' - ? logo_dark - : logo_light) as SettingsService['signal'], }), MockProvider(OrganisationService, { initialised: signal(true), @@ -66,9 +58,6 @@ describe('ApplicationSidebarComponent', () => { beforeEach(() => { app_settings = { 'app.features': ['events'] }; setGroups([]); - theme.set('light'); - logo_dark.set('dark-logo.png'); - logo_light.set('light-logo.png'); spectator = createComponent(); }); @@ -131,19 +120,6 @@ describe('ApplicationSidebarComponent', () => { ]); }); - it('should show the logo for the current theme', () => { - expect(spectator.component.logo_src()).toBe('light-logo.png'); - theme.set('dark'); - expect(spectator.component.logo_src()).toBe('dark-logo.png'); - }); - - it('should resolve the logo from an object or an empty value', () => { - logo_light.set({ src: 'nested-logo.png' }); - expect(spectator.component.logo_src()).toBe('nested-logo.png'); - logo_light.set(null); - expect(spectator.component.logo_src()).toBe(''); - }); - describe('with translations loaded after render', () => { const translations: Record = {}; beforeAll(() => diff --git a/apps/control/ngsw-config.json b/apps/control/ngsw-config.json deleted file mode 100644 index 3b65a2681c1..00000000000 --- a/apps/control/ngsw-config.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "$schema": "../../node_modules/@angular/service-worker/config/schema.json", - "index": "/index.html", - "assetGroups": [ - { - "name": "app", - "installMode": "prefetch", - "resources": { - "files": [ - "/favicon.ico", - "/index.html", - "/manifest.webmanifest", - "/*.css", - "/*.js" - ] - } - }, - { - "name": "assets", - "installMode": "lazy", - "updateMode": "prefetch", - "resources": { - "files": [ - "/assets/**", - "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" - ] - } - } - ], - "dataGroups": [ - { - "name": "s3", - "urls": [ - "https://*.amazonaws.com/**/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" - ], - "cacheConfig": { - "strategy": "performance", - "maxSize": 200, - "maxAge": "7d" - } - } - ] -} diff --git a/apps/control/project.json b/apps/control/project.json index 66e4f596213..b4ac29a986b 100644 --- a/apps/control/project.json +++ b/apps/control/project.json @@ -67,7 +67,7 @@ "with": "apps/control/src/environments/settings.ts" } ], - "serviceWorker": "apps/control/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "production": { "fileReplacements": [ @@ -105,7 +105,7 @@ "maximumError": "10kb" } ], - "serviceWorker": "apps/control/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "mobile": { "fileReplacements": [ diff --git a/apps/control/src/app/tabbed-view/tabbed-view.component.ts b/apps/control/src/app/tabbed-view/tabbed-view.component.ts index 2ff9b8e18df..49cd8fa4c9a 100644 --- a/apps/control/src/app/tabbed-view/tabbed-view.component.ts +++ b/apps/control/src/app/tabbed-view/tabbed-view.component.ts @@ -1,15 +1,14 @@ -import { Component, computed, effect, inject } from '@angular/core'; +import { Component, effect, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; -import { SettingsService } from '@placeos/common'; +import { themedLogo } from '@placeos/common'; import { AuthenticatedImageDirective, IconComponent, TranslatePipe, } from '@placeos/components'; -import { OrganisationService } from '@placeos/common'; import { ControlStateService } from '../control-state.service'; import { ControlStatusBarComponent } from '../status-bar.component'; import { TopbarHeaderComponent } from '../topbar-header.component'; @@ -51,12 +50,7 @@ import { TabOutletComponent } from './tab-outlet.component'; class="bg-base-100 absolute inset-0 flex flex-col items-center justify-center space-y-2 p-16" >
- Logo + Logo

- @if (!loading()) { -
- @if (regions().length > 1) { - - - - -
-
- {{ - active_region()?.display_name || - active_region()?.name - }} -
-
- {{ active_region()?.id }} -
-
-
- @for (option of regions(); track option) { - -
-
- {{ - option.display_name || - option.name - }} -
-
- {{ option.id - }} -
-
-
- } -
-
- } - @if (buildings().length) { - - - - -
-
- {{ - active_building() - ?.display_name || - active_building()?.name - }} -
-
- {{ active_building()?.id }} -
-
-
- @for (option of buildings(); track option) { - -
-
- {{ - option.display_name || - option.name - }} -
-
- {{ option.id - }} -
-
-
- } -
-
- } - @if (levels().length && active_building()) { -
- - - - -
-
- {{ - active_level()?.display_name || - active_level()?.name - }} -
-
- {{ active_level()?.id }} -
-
-
- @for (option of levels(); track option) { - -
-
- {{ - option.display_name || - option.name - }} -
-
- {{ option.id - }} -
-
-
- } -
-
- @if (active_level()?.tags.includes('parking')) { - Show as fixed parking display - } - } - @if (rotations().length) { -
- - - - @for (option of rotations(); track option) { - -
-
- {{ - option.display_name || - option.name - }} -
-
- {{ option.id - }} -
-
-
- } -
-
- } - @if (locations().length) { -
- - - - @for (option of locations(); track option) { - -
-
- {{ - option.display_name || - option.name - }} -
-
- {{ option.id - }} -
-
-
- } -
-
- } -
- } @else { -
- -

{{ loading() }}

-
- } - @if (!loading()) { -
- -
- } + } +
@@ -329,25 +75,11 @@ import {
`, - styles: [ - ` - mat-form-field { - width: 100%; - } - - label { - padding-top: 1rem; - } - `, - ], imports: [ - MatRippleModule, - MatProgressSpinnerModule, - MatFormFieldModule, - MatSelectModule, - SettingsToggleComponent, - CommonModule, + DatePipe, FormsModule, + KioskLocationPickerComponent, + SettingsToggleComponent, TranslatePipe, ], }) @@ -368,60 +100,13 @@ export class BootstrapComponent extends AsyncHandler implements OnInit { untracked(() => this.handleQueryParams(params)); }); - public get version() { - return VERSION; - } + public readonly version = VERSION; - /** Loading state of the bootstrap */ - public readonly loading = signal(null); - /** Actively selected building */ - public readonly active_region = signal(null); - /** Actively selected building */ - public readonly active_building = signal(null); - /** Actively selected level */ - public readonly active_level = signal(null); - /** Actively selected level */ - public readonly active_rotation = signal(null); - /** Actively selected location */ - public readonly active_location = signal(null); /** Whether to show the map as a parking view */ public readonly parking = signal(false); - public readonly rotations = signal([]); - - public readonly regions = this._org.region_list; - public readonly buildings = this._org.active_buildings; - public readonly levels = this._org.active_levels; - - public setRegion(region: Region) { - this._org.region = region; - this.active_region.set(region); - this.active_building.set(null); - this.active_level.set(null); - this.active_location.set(null); - this.updateRotations(); - } - - public setBuilding(building: Building) { - this._org.building = building; - this.active_building.set(building); - this.active_level.set(null); - this.active_location.set(null); - this.updateRotations(); - } - - /** List of available locations */ - public readonly locations = computed((): readonly Identity[] => { - const active_level = this.active_level(); - if (!active_level) { - return []; - } - return active_level.locations || []; - }); - public async ngOnInit() { await this._org.waitUntilInitialised(); - this.active_region.set(this._org.region); this._ready.set(true); this.timeout('check', () => this.checkBootstrap(), 1000); } @@ -433,95 +118,42 @@ export class BootstrapComponent extends AsyncHandler implements OnInit { localStorage.setItem('OSK.enabled', `${osk_enabled}`); } if (params.has('clear') && params.get('clear') === 'true') { - localStorage.removeItem('KIOSK.building'); - localStorage.removeItem('KIOSK.level'); + clearKioskLocation(); localStorage.removeItem('KIOSK.parking'); - localStorage.removeItem('KIOSK.orientation'); - localStorage.removeItem('KIOSK.location'); } if (params.has('level')) { const level = this._org.levelWithID([params.get('level')]); if (level) { - this.active_level.set(level); - this.bootstrapKiosk(); - } - } - } - - public updateRotations() { - this.rotations.set([]); - const active_building = this.active_building(); - if (!active_building) { - this.active_rotation.set(null); - return; - } - const orientations = active_building.orientations; - const rotations: Identity[] = []; - for (const key in orientations) { - if (orientations[key]) { - rotations.push({ - id: key, - name: `${key.split('_').join(' ')} (${ - orientations[key] * 90 - }°)`, - value: orientations[key], + this.bootstrapKiosk({ + building: null, + level, + orientation: null, + location: null, }); } } - this.rotations.set(rotations); - this.active_rotation.set(rotations[0] || null); } /** * Store bootstrapped values and navigate to the main page */ - public bootstrapKiosk() { - this.loading.set('Bootstrapping application...'); - const active_level = this.active_level(); - const active_building = this.active_building(); - const active_rotation = this.active_rotation(); - const active_location = this.active_location(); + public bootstrapKiosk(kiosk: KioskLocation) { const parking = this.parking(); - if (active_level) { - if (localStorage) { - localStorage.setItem( - 'KIOSK.building', - active_building?.id || active_level.parent_id, - ); - localStorage.setItem('KIOSK.level', active_level.id); - if (parking) localStorage.setItem('KIOSK.parking', `true`); - else localStorage.removeItem('KIOSK.parking'); - if (active_rotation) { - localStorage.setItem( - 'KIOSK.orientation', - `${active_rotation.id}`, - ); - } else localStorage.removeItem('KIOSK.orientation'); - if (active_location) { - localStorage.setItem( - 'KIOSK.location', - `${active_location.id}`, - ); - } else localStorage.removeItem('KIOSK.location'); - } - this._router.navigate([parking ? '/parking' : '/explore']); - } - this.loading.set(null); + saveKioskLocation(kiosk); + if (parking) localStorage.setItem('KIOSK.parking', `true`); + else localStorage.removeItem('KIOSK.parking'); + this._router.navigate([parking ? '/parking' : '/explore']); } /** * Check for any existing bootstrapped values */ private checkBootstrap() { - this.loading.set('Checking for existing parameters...'); - const building_id = localStorage?.getItem('KIOSK.building'); - const level_id = localStorage?.getItem('KIOSK.level'); - const parking = localStorage?.getItem('KIOSK.parking'); - if (building_id && level_id) { + if (hasKioskLocation()) { + const parking = localStorage.getItem('KIOSK.parking'); this._router.navigate([parking ? '/parking' : '/explore']); } VirtualKeyboardComponent.enabled = localStorage.getItem('OSK.enabled') === 'true'; - this.loading.set(null); } } diff --git a/apps/map-kiosk/src/tests/bootstrap.component.spec.ts b/apps/map-kiosk/src/tests/bootstrap.component.spec.ts index 1f338b9c882..4c49a5ed4e3 100644 --- a/apps/map-kiosk/src/tests/bootstrap.component.spec.ts +++ b/apps/map-kiosk/src/tests/bootstrap.component.spec.ts @@ -1,7 +1,4 @@ import { signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; import { Router } from '@angular/router'; import { SpectatorRouting, @@ -43,7 +40,6 @@ describe('BootstrapComponent', () => { initialised: signal(true), }), ], - imports: [MatFormFieldModule, MatSelectModule, FormsModule], }); beforeEach(() => { @@ -57,77 +53,24 @@ describe('BootstrapComponent', () => { vi.useRealTimers(); }); - it('should allow selecting level', async () => { - expect('[level]').not.toExist(); - expect('mat-select').toHaveLength(1); - expect('button').toBeDisabled(); - spectator.click('[building]'); - spectator.click(document.querySelector('mat-option')); - spectator.detectChanges(); - - expect(spectator.component.active_building()?.id).toBe('1'); - expect(spectator.component.active_level()).toBeFalsy(); - expect(spectator.component.levels().length).toBe(2); - expect('[level]').toExist(); - expect('button').toBeDisabled(); - spectator.click('[level]'); - spectator.click(document.querySelector('mat-option')); - spectator.detectChanges(); - expect(spectator.component.active_level()).toBeTruthy(); - expect('button').not.toBeDisabled(); - }); + it('should save the parking flag and route to the matching view', () => { + const router = spectator.inject(Router); + const kiosk = { + building: null, + level: new BuildingLevel({ id: 'lvl-1', parent_id: 'bld-1' }), + orientation: null, + location: null, + }; + spectator.component.parking.set(true); + spectator.component.bootstrapKiosk(kiosk); + expect(localStorage.getItem('KIOSK.level')).toBe('lvl-1'); + expect(localStorage.getItem('KIOSK.parking')).toBe('true'); + expect(router.navigate).toHaveBeenCalledWith(['/parking']); - it('should handling bootstrapping', () => { - vi.spyOn(Storage.prototype, 'setItem'); - vi.spyOn(Storage.prototype, 'removeItem'); - expect(localStorage.setItem).not.toHaveBeenCalled(); - spectator.component.bootstrapKiosk(); - expect(localStorage.setItem).not.toHaveBeenCalled(); - spectator.component.active_building.set(new Building({ id: 'bld-1' })); - spectator.component.bootstrapKiosk(); - expect(localStorage.setItem).not.toHaveBeenCalled(); - spectator.component.active_level.set( - new BuildingLevel({ id: 'lvl-1' }), - ); - localStorage.setItem('KIOSK.parking', 'true'); - localStorage.setItem('KIOSK.orientation', '180'); - localStorage.setItem('KIOSK.location', 'old-kiosk'); - spectator.component.bootstrapKiosk(); - expect(localStorage.setItem).toHaveBeenCalledWith( - 'KIOSK.building', - 'bld-1', - ); - expect(localStorage.setItem).toHaveBeenCalledWith( - 'KIOSK.level', - 'lvl-1', - ); - expect(localStorage.setItem).not.toHaveBeenCalledWith( - 'KIOSK.orientation', - '90', - ); - expect(localStorage.setItem).not.toHaveBeenCalledWith( - 'KIOSK.location', - 'kiosk-1', - ); - expect(localStorage.removeItem).toHaveBeenCalledWith('KIOSK.parking'); - expect(localStorage.removeItem).toHaveBeenCalledWith( - 'KIOSK.orientation', - ); - expect(localStorage.removeItem).toHaveBeenCalledWith('KIOSK.location'); - spectator.component.active_rotation.set({ id: '90', name: '' }); - spectator.component.active_location.set({ - id: 'kiosk-1', - name: 'South West', - }); - spectator.component.bootstrapKiosk(); - expect(localStorage.setItem).toHaveBeenCalledWith( - 'KIOSK.orientation', - '90', - ); - expect(localStorage.setItem).toHaveBeenCalledWith( - 'KIOSK.location', - 'kiosk-1', - ); + spectator.component.parking.set(false); + spectator.component.bootstrapKiosk(kiosk); + expect(localStorage.getItem('KIOSK.parking')).toBeNull(); + expect(router.navigate).toHaveBeenCalledWith(['/explore']); }); it('should handle clearing bootstrap details', () => { diff --git a/apps/visitor-kiosk/src/app/bootstrap.component.ts b/apps/visitor-kiosk/src/app/bootstrap.component.ts index fe68c897cb5..1cd9ea074a7 100644 --- a/apps/visitor-kiosk/src/app/bootstrap.component.ts +++ b/apps/visitor-kiosk/src/app/bootstrap.component.ts @@ -1,31 +1,31 @@ -import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { Component, inject, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { AsyncHandler, - Building, - BuildingLevel, - Identity, isPublicMode, OrganisationService, - Region, SettingsService, VERSION, } from '@placeos/common'; - -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { MatRippleModule } from '@angular/material/core'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatSelectModule } from '@angular/material/select'; import { - AnnounceDirective, + clearKioskLocation, + hasKioskLocation, + KioskLocation, + KioskLocationPickerComponent, + saveKioskLocation, TranslatePipe, VirtualKeyboardComponent, } from '@placeos/components'; import { PageHeadingDirective } from './components/page-heading.directive'; +/** + * Setup screen that picks the location the visitor kiosk shows. + * `?level=` saves that level at once, `?clear=true` removes the saved + * setup, `?osk=` turns the on-screen keyboard on or off and + * `?action=preferences` opens the check-in preferences. + */ @Component({ selector: '[bootstrap]', template: ` @@ -49,320 +49,7 @@ import { PageHeadingDirective } from './components/page-heading.directive';

- @if (!loading()) { -
- @if (regions().length > 1) { - - - - -
-
- {{ - active_region() - ?.display_name || - active_region()?.name - }} -
-
- {{ active_region()?.id }} -
-
-
- @for (option of regions(); track option) { - -
-
- {{ - option.display_name || - option.name - }} -
-
- {{ option.id - }} -
-
-
- } -
-
- } - @if (buildings().length) { - - - - -
-
- {{ - active_building() - ?.display_name || - active_building()?.name - }} -
-
- {{ active_building()?.id }} -
-
-
- @for (option of buildings(); track option) { - -
-
- {{ - option.display_name || - option.name - }} -
-
- {{ option.id - }} -
-
-
- } -
-
- } - @if (levels().length && active_building()) { -
- - - - -
-
- {{ - active_level() - ?.display_name || - active_level()?.name - }} -
-
- {{ active_level()?.id }} -
-
-
- @for (option of levels(); track option) { - -
-
- {{ - option.display_name || - option.name - }} -
-
- {{ option.id - }} -
-
-
- } -
-
- } - @if (rotations().length) { -
- - - - @for (option of rotations(); track option) { - -
-
- {{ - option.display_name || - option.name - }} -
-
- {{ option.id - }} -
-
-
- } -
-
- } - @if (locations().length) { -
- - - - @for (option of locations(); track option) { - -
-
- {{ - option.display_name || - option.name - }} -
-
- {{ option.id - }} -
-
-
- } -
-
- } -
- } @else { -
- -

{{ loading() }}

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

@@ -391,27 +78,11 @@ import { PageHeadingDirective } from './components/page-heading.directive'; }
`, - styles: [ - ` - mat-form-field { - width: 100%; - } - - label { - padding-top: 1rem; - } - `, - ], imports: [ PageHeadingDirective, - AnnounceDirective, - CommonModule, - MatRippleModule, + DatePipe, + KioskLocationPickerComponent, TranslatePipe, - MatProgressSpinnerModule, - MatFormFieldModule, - MatSelectModule, - FormsModule, ], }) export class BootstrapComponent extends AsyncHandler implements OnInit { @@ -420,61 +91,16 @@ export class BootstrapComponent extends AsyncHandler implements OnInit { private _route = inject(ActivatedRoute); private _router = inject(Router); private _startup_action = ''; + private _last_query_params = ''; public get version() { return VERSION; } - /** Loading state of the bootstrap */ - public readonly loading = signal(''); - /** Actively selected building */ - public readonly active_region = signal(null); - /** Actively selected building */ - public readonly active_building = signal(null); - /** Actively selected level */ - public readonly active_level = signal(null); - /** Actively selected level */ - public readonly active_rotation = signal(null); - /** Actively selected location */ - public readonly active_location = signal(null); - - public readonly rotations = signal([]); - private _last_query_params = ''; - - public readonly regions = this._org.region_list; - public readonly buildings = this._org.active_buildings; - public readonly levels = this._org.active_levels; public readonly is_public_mode = isPublicMode; - public setRegion(region: Region) { - this._org.region = region; - this.active_region.set(region); - this.active_building.set(null); - this.active_level.set(null); - this.active_location.set(null); - this.updateRotations(); - } - - public setBuilding(building: Building) { - this._org.building = building; - this.active_building.set(building); - this.active_level.set(null); - this.active_location.set(null); - this.updateRotations(); - } - - /** List of available locations */ - public readonly locations = computed((): readonly Identity[] => { - const active_level = this.active_level(); - if (!active_level) { - return []; - } - return active_level.locations || []; - }); - public async ngOnInit() { await this._org.waitUntilInitialised(); - this.active_region.set(this._org.region); // Emits the current params at once, then on each later change this.subscription( 'query_params', @@ -483,85 +109,27 @@ export class BootstrapComponent extends AsyncHandler implements OnInit { this.checkBootstrap(); } - public updateRotations() { - this.rotations.set([]); - const active_building = this.active_building(); - if (!active_building) { - this.active_rotation.set(null); - return; - } - const orientations = active_building.orientations; - const rotations: Identity[] = []; - for (const key in orientations) { - if (orientations[key]) { - rotations.push({ - id: key, - name: `${key.split('_').join(' ')} (${ - orientations[key] * 90 - }°)`, - value: orientations[key], - }); - } - } - this.rotations.set(rotations); - this.active_rotation.set(rotations[0] || null); - } - /** * Store bootstrapped values and navigate to the main page */ - public bootstrapKiosk() { - this.loading.set('Bootstrapping application...'); - const active_level = this.active_level(); - const active_building = this.active_building(); - const active_rotation = this.active_rotation(); - const active_location = this.active_location(); - if (active_level) { - if (localStorage) { - localStorage.setItem( - 'KIOSK.building', - active_building?.id || active_level.parent_id, - ); - localStorage.setItem('KIOSK.level', active_level.id); - if (active_rotation) { - localStorage.setItem( - 'KIOSK.orientation', - `${active_rotation.id}`, - ); - } else localStorage.removeItem('KIOSK.orientation'); - if (active_location) { - localStorage.setItem( - 'KIOSK.location', - `${active_location.id}`, - ); - } else localStorage.removeItem('KIOSK.location'); - } - this.navigateToStartupRoute(); - } - this.loading.set(''); + public bootstrapKiosk(kiosk: KioskLocation) { + saveKioskLocation(kiosk); + this.navigateToStartupRoute(); } /** * Check for any existing bootstrapped values */ private checkBootstrap() { - this.loading.set('Checking for existing parameters...'); if (this._startup_action === 'preferences') { this.navigateToStartupRoute(); - this.loading.set(''); return; } - if (localStorage) { - const building_id = localStorage.getItem('KIOSK.building'); - const level_id = localStorage.getItem('KIOSK.level'); - if (building_id && level_id) { - this._router.navigate(this.getStartupRoute()); - } + if (hasKioskLocation()) { + this._router.navigate(this.getStartupRoute()); } - VirtualKeyboardComponent.enabled = localStorage.getItem('OSK.enabled') === 'true'; - this.loading.set(''); } private getStartupRoute() { @@ -595,17 +163,16 @@ export class BootstrapComponent extends AsyncHandler implements OnInit { const osk_enabled = query_params.osk === 'true'; localStorage.setItem('OSK.enabled', `${osk_enabled}`); } - if (query_params.clear === 'true') { - localStorage.removeItem('KIOSK.building'); - localStorage.removeItem('KIOSK.level'); - localStorage.removeItem('KIOSK.orientation'); - localStorage.removeItem('KIOSK.location'); - } + if (query_params.clear === 'true') clearKioskLocation(); if (query_params.level) { const level = this._org.levelWithID([query_params.level]); if (level) { - this.active_level.set(level); - this.bootstrapKiosk(); + this.bootstrapKiosk({ + building: null, + level, + orientation: null, + location: null, + }); } } } diff --git a/apps/visitor-kiosk/src/tests/bootstrap.component.spec.ts b/apps/visitor-kiosk/src/tests/bootstrap.component.spec.ts index fc61be183e5..2bdbc4f31da 100644 --- a/apps/visitor-kiosk/src/tests/bootstrap.component.spec.ts +++ b/apps/visitor-kiosk/src/tests/bootstrap.component.spec.ts @@ -1,7 +1,4 @@ import { signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; import { Router } from '@angular/router'; import { createRoutingFactory, @@ -45,7 +42,6 @@ describe('BootstrapComponent', () => { }), MockProvider(SettingsService, { get: vi.fn() }), ], - imports: [MatFormFieldModule, MatSelectModule, FormsModule], }); beforeEach(() => { @@ -54,67 +50,6 @@ describe('BootstrapComponent', () => { spectator = createComponent(); }); - it('should allow selecting level', async () => { - expect('[level]').not.toExist(); - expect('mat-select').toHaveLength(1); - spectator.click('[building]'); - spectator.click(document.querySelector('mat-option')); - spectator.detectChanges(); - - expect(spectator.component.active_building()?.id).toBe('1'); - expect(spectator.component.active_level()).toBeFalsy(); - expect(spectator.component.levels().length).toBe(2); - expect('[level]').toExist(); - spectator.click('[level]'); - spectator.click(document.querySelector('mat-option')); - spectator.detectChanges(); - expect(spectator.component.active_level()).toBeTruthy(); - }); - - it('should handling bootstrapping', () => { - vi.spyOn(Storage.prototype, 'setItem'); - expect(localStorage.setItem).not.toHaveBeenCalled(); - spectator.component.bootstrapKiosk(); - expect(localStorage.setItem).not.toHaveBeenCalled(); - spectator.component.active_building.set(new Building({ id: 'bld-1' })); - spectator.component.bootstrapKiosk(); - expect(localStorage.setItem).not.toHaveBeenCalled(); - spectator.component.active_level.set( - new BuildingLevel({ id: 'lvl-1' }), - ); - spectator.component.bootstrapKiosk(); - expect(localStorage.setItem).toHaveBeenCalledWith( - 'KIOSK.building', - 'bld-1', - ); - expect(localStorage.setItem).toHaveBeenCalledWith( - 'KIOSK.level', - 'lvl-1', - ); - expect(localStorage.setItem).not.toHaveBeenCalledWith( - 'KIOSK.orientation', - '90', - ); - expect(localStorage.setItem).not.toHaveBeenCalledWith( - 'KIOSK.location', - 'kiosk-1', - ); - spectator.component.active_rotation.set({ id: '90', name: '' }); - spectator.component.active_location.set({ - id: 'kiosk-1', - name: 'South West', - }); - spectator.component.bootstrapKiosk(); - expect(localStorage.setItem).toHaveBeenCalledWith( - 'KIOSK.orientation', - '90', - ); - expect(localStorage.setItem).toHaveBeenCalledWith( - 'KIOSK.location', - 'kiosk-1', - ); - }); - it('should handle clearing bootstrap details', () => { vi.spyOn(Storage.prototype, 'removeItem'); expect(localStorage.removeItem).not.toHaveBeenCalled(); @@ -131,13 +66,14 @@ describe('BootstrapComponent', () => { const router = spectator.inject(Router); spectator.setRouteQueryParam('action', 'preferences'); spectator.setRouteQueryParam('token', 'abc.123'); - spectator.component.active_building.set(new Building({ id: 'bld-1' })); - spectator.component.active_level.set( - new BuildingLevel({ id: 'lvl-1' }), - ); spectator.detectChanges(); - spectator.component.bootstrapKiosk(); + spectator.component.bootstrapKiosk({ + building: new Building({ id: 'bld-1' }), + level: new BuildingLevel({ id: 'lvl-1' }), + orientation: null, + location: null, + }); expect(router.navigate).toHaveBeenCalledWith( ['/checkin', 'preferences'], diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 0a0c3e5bd10..b61ea8542c9 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -18,6 +18,7 @@ export * from './lib/global-loading.component'; export * from './lib/icon.component'; export * from './lib/image-carousel.component'; export * from './lib/interactive-map.component'; +export * from './lib/kiosk-location-picker.component'; export * from './lib/load-error.component'; export * from './lib/map-locate-modal.component'; export * from './lib/map-pin.component'; @@ -35,6 +36,7 @@ export * from './lib/settings-debug-panel.component'; export * from './lib/settings-toggle.component'; export * from './lib/simple-table.component'; export * from './lib/survey-outlet.component'; +export * from './lib/system-bootstrap.component'; export * from './lib/translate.pipe'; export * from './lib/unauthorised.component'; export * from './lib/user-avatar.component'; diff --git a/libs/components/src/lib/kiosk-location-picker.component.ts b/libs/components/src/lib/kiosk-location-picker.component.ts new file mode 100644 index 00000000000..7a1d3fa948b --- /dev/null +++ b/libs/components/src/lib/kiosk-location-picker.component.ts @@ -0,0 +1,381 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { + Component, + computed, + inject, + OnInit, + output, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatRippleModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; + +import { + Building, + BuildingLevel, + Identity, + OrganisationService, + Region, +} from '@placeos/common'; + +import { TranslatePipe } from './translate.pipe'; + +/** Location that a kiosk shows, as picked on its setup screen */ +export interface KioskLocation { + building: Building | null; + level: BuildingLevel; + orientation: Identity | null; + location: Identity | null; +} + +/** + * Save the kiosk location to local storage. + * The building falls back to the parent of the level. + * An unset orientation or location removes the saved value. + */ +export function saveKioskLocation(kiosk: KioskLocation) { + const { building, level, orientation, location } = kiosk; + localStorage.setItem('KIOSK.building', building?.id || level.parent_id); + localStorage.setItem('KIOSK.level', level.id); + if (orientation) { + localStorage.setItem('KIOSK.orientation', `${orientation.id}`); + } else localStorage.removeItem('KIOSK.orientation'); + if (location) localStorage.setItem('KIOSK.location', `${location.id}`); + else localStorage.removeItem('KIOSK.location'); +} + +/** Remove the saved kiosk location from local storage */ +export function clearKioskLocation() { + localStorage.removeItem('KIOSK.building'); + localStorage.removeItem('KIOSK.level'); + localStorage.removeItem('KIOSK.orientation'); + localStorage.removeItem('KIOSK.location'); +} + +/** Whether local storage has a saved kiosk building and level */ +export function hasKioskLocation() { + return ( + !!localStorage.getItem('KIOSK.building') && + !!localStorage.getItem('KIOSK.level') + ); +} + +/** + * Region, building, level, orientation and location selects for the setup + * screen of a kiosk. Emits `submitted` when the user finishes setup with a + * level selected. Projected content shows below the level select. + * + * ```html + * + * ``` + */ +@Component({ + selector: 'kiosk-location-picker', + template: ` +
+ @if (regions().length > 1) { + + + + +
+
+ {{ + active_region()?.display_name || + active_region()?.name + }} +
+
+ {{ active_region()?.id }} +
+
+
+ @for (option of regions(); track option) { + + + + } +
+
+ } + @if (buildings().length) { + + + + +
+
+ {{ + active_building()?.display_name || + active_building()?.name + }} +
+
+ {{ active_building()?.id }} +
+
+
+ @for (option of buildings(); track option) { + + + + } +
+
+ } + @if (levels().length && active_building()) { +
+ + + + +
+
+ {{ + active_level()?.display_name || + active_level()?.name + }} +
+
+ {{ active_level()?.id }} +
+
+
+ @for (option of levels(); track option) { + + + + } +
+
+ + } + @if (rotations().length) { +
+ + + + @for (option of rotations(); track option) { + + + + } + + + } + @if (locations().length) { +
+ + + + @for (option of locations(); track option) { + + + + } + + + } +
+
+ +
+ +
+
{{ option.display_name || option.name }}
+
+ {{ option.id + }} +
+
+
+ `, + styles: [ + ` + :host { + display: block; + } + + mat-form-field { + width: 100%; + } + + label { + padding-top: 1rem; + } + `, + ], + imports: [ + NgTemplateOutlet, + FormsModule, + MatFormFieldModule, + MatRippleModule, + MatSelectModule, + TranslatePipe, + ], +}) +export class KioskLocationPickerComponent implements OnInit { + private _org = inject(OrganisationService); + + /** Emits the picked location when the user finishes setup */ + public readonly submitted = output(); + + /** Actively selected region */ + public readonly active_region = signal(null); + /** Actively selected building */ + public readonly active_building = signal(null); + /** Actively selected level */ + public readonly active_level = signal(null); + /** Actively selected orientation */ + public readonly active_rotation = signal(null); + /** Actively selected location */ + public readonly active_location = signal(null); + + /** Orientations of the active building */ + public readonly rotations = signal([]); + + public readonly regions = this._org.region_list; + public readonly buildings = this._org.active_buildings; + public readonly levels = this._org.active_levels; + + /** Fixed locations of the active level */ + public readonly locations = computed( + (): readonly Identity[] => this.active_level()?.locations || [], + ); + + public async ngOnInit() { + await this._org.waitUntilInitialised(); + this.active_region.set(this._org.region); + } + + public setRegion(region: Region) { + this._org.region = region; + this.active_region.set(region); + this.active_building.set(null); + this.active_level.set(null); + this.active_location.set(null); + this.updateRotations(); + } + + public setBuilding(building: Building) { + this._org.building = building; + this.active_building.set(building); + this.active_level.set(null); + this.active_location.set(null); + this.updateRotations(); + } + + /** List the orientations of the active building and select the first */ + public updateRotations() { + const orientations = this.active_building()?.orientations || {}; + const rotations: Identity[] = []; + for (const key in orientations) { + if (orientations[key]) { + rotations.push({ + id: key, + name: `${key.split('_').join(' ')} (${ + orientations[key] * 90 + }°)`, + value: orientations[key], + }); + } + } + this.rotations.set(rotations); + this.active_rotation.set(rotations[0] || null); + } + + /** Emit the picked location when a level is selected */ + public submit() { + const level = this.active_level(); + if (!level) return; + this.submitted.emit({ + building: this.active_building(), + level, + orientation: this.active_rotation(), + location: this.active_location(), + }); + } +} diff --git a/libs/components/src/lib/system-bootstrap.component.ts b/libs/components/src/lib/system-bootstrap.component.ts new file mode 100644 index 00000000000..00ac65bc75a --- /dev/null +++ b/libs/components/src/lib/system-bootstrap.component.ts @@ -0,0 +1,304 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + computed, + debounced, + effect, + inject, + input, + OnInit, + output, + resource, + signal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatRippleModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { ActivatedRoute, Router } from '@angular/router'; +import { querySystems } from '@placeos/ts-client'; + +import { + getNativeSystemId, + OrganisationService, + Space, + syncNativeManagedConfig, + VERSION, +} from '@placeos/common'; + +import { AnnounceDirective } from './announce.directive'; +import { TranslatePipe } from './translate.pipe'; + +/** + * Setup screen that binds a fixed device to a PlaceOS system. + * + * Search for a system, or pass `?system_id=` (or `?sys_id=`), to save the + * system ID under `store_key` and go to the route from `system_route`. + * `?clear=true` removes the saved system. On load, a system ID from MDM + * managed config overrides the saved system. Otherwise a saved system + * goes to its route at once. + * + * ```html + * + * ``` + */ +@Component({ + selector: 'system-bootstrap', + template: ` +
+
+
+

{{ heading() | translate }}

+
+
+
+ {{ 'COMMON.BOOTSTRAP_SETUP' | translate }} +
+
+
+ @if (!loading() || loading() === 'search') { +

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

+ + {{ + 'COMMON.BOOTSTRAP_LABEL' | translate + }} + + @if (loading() === 'search') { + + } + + + @for (option of space_list(); track option.id) { + +
+
+
+ {{ + option.display_name || + option.name + }} +
+ @if ( + option.display_name && + option.display_name !== option.name + ) { +
+ {{ option.name }} +
+ } +
+
+ {{ option.id }} +
+
+
+ } + @if (system_id().length < 2 && !space_list().length) { + + {{ + 'COMMON.BOOTSTRAP_INPUT_PLACEHOLDER' + | translate + }} + + } +
+ + } @else { +
+ +
+ {{ 'COMMON.BOOTSTRAP_LOADING' | translate }} +
+
+ } +
+
+
+ {{ 'COMMON.CONTROLS_VERSION' | translate }}: + {{ version.hash }} +
+
+ {{ version.time | date: 'longDate' }} + ({{ version.time | date: 'shortTime' }}) +
+
+
+ `, + styles: [ + ` + mat-form-field { + width: calc(100% - 2rem); + } + + button { + width: 8rem; + margin: 0.5rem; + margin-top: 0; + } + `, + ], + imports: [ + CommonModule, + TranslatePipe, + AnnounceDirective, + MatProgressSpinnerModule, + MatRippleModule, + MatAutocompleteModule, + MatFormFieldModule, + MatInputModule, + FormsModule, + ], +}) +export class SystemBootstrapComponent implements OnInit { + private _route = inject(ActivatedRoute); + private _router = inject(Router); + private _org = inject(OrganisationService); + private readonly _query_params = toSignal(this._route.queryParamMap, { + initialValue: this._route.snapshot.queryParamMap, + }); + + /** Local storage key that holds the bootstrapped system ID */ + public readonly store_key = input.required(); + /** Translation key of the application title in the header */ + public readonly heading = input.required(); + /** Translation key of the submit button label */ + public readonly submit_label = input('COMMON.BOOTSTRAP_SUBMIT'); + /** Router commands to open the application for a system ID */ + public readonly system_route = + input.required<(system_id: string) => unknown[]>(); + /** Emits the system ID after it is saved, before navigation */ + public readonly configured = output(); + + public readonly version = VERSION; + + /** Whether application data is loading */ + public readonly loading = signal(''); + /** ID of the system to bootstrap */ + public readonly system_id = signal(''); + + private readonly _debounced_search = debounced(this.system_id, 300); + private readonly _space_list = resource({ + params: () => this._debounced_search.value(), + loader: async ({ params: search }) => { + if (search.length < 2) return [] as Space[]; + this.loading.set('search'); + try { + const { data } = await querySystems({ + q: search, + limit: 20, + fields: ['id', 'name', 'display_name', 'email'].join(','), + zone_id: this._org.organisation?.id, + }); + return data.map( + ({ id, name, display_name, email }) => + new Space({ id, name, display_name, email }), + ); + } finally { + this.loading.set(''); + } + }, + }); + /** Systems that match the search text */ + public readonly space_list = computed(() => + this._space_list.hasValue() ? this._space_list.value() : [], + ); + + constructor() { + effect(() => { + const params = this._query_params(); + if (params.has('clear') && !!params.get('clear')) { + localStorage.removeItem(this.store_key()); + } + const system_id = params.get('system_id') || params.get('sys_id'); + if (system_id) { + this.system_id.set(system_id); + this.configure(system_id); + } + }); + } + + public ngOnInit() { + this.checkBootstrapped(); + } + + /** Bind the application to the system in the search box */ + public readonly bootstrap = () => this.configure(this.system_id()); + + /** + * Go to the saved system, or to the MDM managed system if it differs + */ + private async checkBootstrapped(): Promise { + if (this.system_id()) return; + this.loading.set('Checks'); + // Wait for any MDM managed configuration to be stored locally, as it + // takes precedence over previously stored bootstrap settings. + await syncNativeManagedConfig(); + if (this.system_id()) return; + const system_id = localStorage.getItem(this.store_key()); + // A system pushed via MDM managed config overrides the stored one + const mdm_system_id = getNativeSystemId(); + if (mdm_system_id && mdm_system_id !== system_id) { + this.system_id.set(mdm_system_id); + return this.configure(mdm_system_id); + } + if (system_id) { + this._router.navigate(this.system_route()(system_id), { + queryParamsHandling: 'preserve', + }); + return; + } + this.loading.set(''); + } + + /** Save the system ID as a trusted fixed device and open its route */ + private configure(system_id: string): void { + localStorage.setItem(this.store_key(), system_id); + localStorage.setItem('trust', 'true'); + localStorage.setItem('fixed_device', 'true'); + this.configured.emit(system_id); + this._router.navigate(this.system_route()(system_id), { + queryParamsHandling: 'preserve', + }); + } +} diff --git a/libs/components/src/tests/kiosk-location-picker.component.spec.ts b/libs/components/src/tests/kiosk-location-picker.component.spec.ts new file mode 100644 index 00000000000..5f0de46830e --- /dev/null +++ b/libs/components/src/tests/kiosk-location-picker.component.spec.ts @@ -0,0 +1,94 @@ +import { signal } from '@angular/core'; +import { Spectator, createComponentFactory } from '@ngneat/spectator/vitest'; +import { MockProvider } from 'ng-mocks'; + +import { + Building, + BuildingLevel, + OrganisationService, + Region, +} from '@placeos/common'; +import { + KioskLocationPickerComponent, + saveKioskLocation, +} from '../lib/kiosk-location-picker.component'; + +describe('KioskLocationPickerComponent', () => { + let spectator: Spectator; + const createComponent = createComponentFactory({ + component: KioskLocationPickerComponent, + providers: [ + MockProvider(OrganisationService, { + region_list: signal([new Region({ name: '1', id: '1' })]), + active_buildings: signal([ + new Building({ name: '1', id: '1', parent_id: '1' }), + ]), + active_levels: signal([ + new BuildingLevel({ id: '1' }), + new BuildingLevel({ id: '2' }), + ]), + }), + ], + }); + + beforeEach(() => { + localStorage.clear(); + spectator = createComponent(); + }); + + afterEach(() => localStorage.clear()); + + it('should allow submit once a level is selected', () => { + const submitted = vi.fn(); + spectator.output('submitted').subscribe(submitted); + expect('[level]').not.toExist(); + expect('mat-select').toHaveLength(1); + expect('button').toBeDisabled(); + spectator.click('[building]'); + spectator.click(document.querySelector('mat-option')); + spectator.detectChanges(); + + expect(spectator.component.active_building()?.id).toBe('1'); + expect(spectator.component.active_level()).toBeFalsy(); + expect('[level]').toExist(); + expect('button').toBeDisabled(); + spectator.click('[level]'); + spectator.click(document.querySelector('mat-option')); + spectator.detectChanges(); + expect('button').not.toBeDisabled(); + + spectator.click('button'); + expect(submitted).toHaveBeenCalledWith({ + building: spectator.component.active_building(), + level: spectator.component.active_level(), + orientation: null, + location: null, + }); + }); + + it('should save the kiosk location', () => { + localStorage.setItem('KIOSK.orientation', '180'); + localStorage.setItem('KIOSK.location', 'old-kiosk'); + const level = new BuildingLevel({ id: 'lvl-1', parent_id: 'bld-0' }); + saveKioskLocation({ + building: null, + level, + orientation: null, + location: null, + }); + expect(localStorage.getItem('KIOSK.building')).toBe('bld-0'); + expect(localStorage.getItem('KIOSK.level')).toBe('lvl-1'); + expect(localStorage.getItem('KIOSK.orientation')).toBeNull(); + expect(localStorage.getItem('KIOSK.location')).toBeNull(); + + saveKioskLocation({ + building: new Building({ id: 'bld-1' }), + level, + orientation: { id: '90', name: '' }, + location: { id: 'kiosk-1', name: 'South West' }, + }); + expect(localStorage.getItem('KIOSK.building')).toBe('bld-1'); + expect(localStorage.getItem('KIOSK.orientation')).toBe('90'); + expect(localStorage.getItem('KIOSK.location')).toBe('kiosk-1'); + }); +}); diff --git a/libs/components/src/tests/system-bootstrap.component.spec.ts b/libs/components/src/tests/system-bootstrap.component.spec.ts new file mode 100644 index 00000000000..2a062e8d981 --- /dev/null +++ b/libs/components/src/tests/system-bootstrap.component.spec.ts @@ -0,0 +1,114 @@ +import { FormsModule } from '@angular/forms'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { Router } from '@angular/router'; +import { + createRoutingFactory, + SpectatorRouting, +} from '@ngneat/spectator/vitest'; +import { MockModule, MockProvider } from 'ng-mocks'; + +import { SettingsService } from '@placeos/common'; +import * as client from '@placeos/ts-client'; +import { SystemBootstrapComponent } from '../lib/system-bootstrap.component'; + +vi.mock('@placeos/ts-client', { spy: true }); + +describe('SystemBootstrapComponent', () => { + const STORE_KEY = 'TEST.system'; + let spectator: SpectatorRouting; + const createComponent = createRoutingFactory({ + component: SystemBootstrapComponent, + providers: [MockProvider(SettingsService, { get: vi.fn() })], + imports: [ + FormsModule, + MockModule(MatAutocompleteModule), + MockModule(MatFormFieldModule), + MockModule(MatInputModule), + MockModule(MatProgressSpinnerModule), + ], + }); + + beforeEach(() => { + vi.useFakeTimers(); + spectator = createComponent({ + props: { + store_key: STORE_KEY, + heading: 'TITLE', + system_route: (id: string) => ['/view', id], + }, + }); + }); + + afterEach(() => { + localStorage.clear(); + vi.useRealTimers(); + }); + + it('should not allow for submit when no system is set', () => { + spectator.detectChanges(); + expect('[load]').not.toExist(); + expect('p.description').toExist(); + const button: HTMLButtonElement = spectator.query('button'); + expect(button.disabled).toBeTruthy(); + spectator.component.system_id.set('sys-B0'); + spectator.detectChanges(); + expect(button.disabled).toBeFalsy(); + }); + + it('should save the system and route to it on submit', () => { + const configured = vi.fn(); + spectator.output('configured').subscribe(configured); + spectator.component.system_id.set('sys-B0'); + spectator.detectChanges(); + spectator.click('button'); + expect(localStorage.getItem(STORE_KEY)).toBe('sys-B0'); + expect(localStorage.getItem('trust')).toBe('true'); + expect(localStorage.getItem('fixed_device')).toBe('true'); + expect(configured).toHaveBeenCalledWith('sys-B0'); + expect(spectator.inject(Router).navigate).toHaveBeenCalledWith( + ['/view', 'sys-B0'], + { queryParamsHandling: 'preserve' }, + ); + }); + + it('should auto bootstrap if there is a system query parameter', () => { + spectator.setRouteQueryParam('system_id', 'sys-B0'); + spectator.detectChanges(); + vi.runOnlyPendingTimers(); + expect(spectator.inject(Router).navigate).toHaveBeenCalledWith( + ['/view', 'sys-B0'], + { queryParamsHandling: 'preserve' }, + ); + }); + + it('should route to a saved system on load', async () => { + localStorage.setItem(STORE_KEY, 'sys-B1'); + spectator.component.ngOnInit(); + await vi.runOnlyPendingTimersAsync(); + expect(spectator.inject(Router).navigate).toHaveBeenCalledWith( + ['/view', 'sys-B1'], + { queryParamsHandling: 'preserve' }, + ); + }); + + it('should clear bootstrap if there is a clear query parameter', () => { + localStorage.setItem(STORE_KEY, 'sys-B0'); + spectator.setRouteQueryParam('clear', 'true'); + spectator.detectChanges(); + vi.runOnlyPendingTimers(); + expect(localStorage.getItem(STORE_KEY)).toBeFalsy(); + }); + + it('should show no results when the system search fails', async () => { + vi.mocked(client.querySystems).mockRejectedValue(new Error('401')); + spectator.component.system_id.set('Room'); + spectator.detectChanges(); + await vi.advanceTimersByTimeAsync(400); + expect(client.querySystems).toHaveBeenCalled(); + expect(spectator.component.space_list()).toEqual([]); + expect(() => spectator.detectChanges()).not.toThrow(); + }); +}); From 879340451d314f2d40aa0ff955a52ea5a9228b8e Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 17:07:53 +1100 Subject: [PATCH 06/21] refactor(libs): move code that apps imported from other apps - Desk and parking request form details move to libs/bookings. - Fullscreen embed moves to libs/components. - The map-kiosk explore page moves to libs/explore. Visitor kiosk turns off its idle reset, so only one idle timer runs on /explore. --- .../app/desks/desk-book-modal.component.ts | 7 +- .../parking-request-modal.component.ts | 2 +- apps/map-kiosk/src/app/app.routes.ts | 2 +- apps/map-kiosk/src/app/explore.route.ts | 3 + apps/visitor-kiosk/src/app/explore.routes.ts | 17 +++- .../desk-flow/desk-flow-form.component.ts | 6 +- .../parking-request-form.component.ts | 7 +- .../virtual-concierge-button.component.ts | 7 +- .../desk-flow-form.component.spec.ts | 6 +- .../parking-request-form.component.spec.ts | 7 +- ...virtual-concierge-button.component.spec.ts | 2 +- docs/settings/visitor-kiosk.md | 2 +- libs/bookings/src/index.ts | 2 + .../src/lib}/desk-form-details.component.ts | 33 ++++--- .../parking-request-form-details.component.ts | 23 ++--- .../test}/desk-form-details.component.spec.ts | 6 +- ...ing-request-form-details.component.spec.ts | 14 +-- .../parking-request-special-needs.spec.ts | 5 +- libs/components/src/index.ts | 1 + .../src/lib}/fullscreen-embed.component.ts | 4 +- .../tests}/fullscreen-embed.component.spec.ts | 5 +- libs/explore/src/index.ts | 1 + .../lib}/accessibility-controls.component.ts | 0 .../explore/src/lib}/explore.component.ts | 50 ++++++----- .../accessibility-controls.component.spec.ts | 2 +- .../src/tests/explore.component.spec.ts | 88 +++++++++++++++++++ 26 files changed, 214 insertions(+), 88 deletions(-) create mode 100644 apps/map-kiosk/src/app/explore.route.ts rename {apps/workplace/src/app/book/desk-flow => libs/bookings/src/lib}/desk-form-details.component.ts (95%) rename {apps/workplace/src/app/book/parking-request-flow => libs/bookings/src/lib}/parking-request-form-details.component.ts (99%) rename {apps/workplace/src/tests/book/desk-flow => libs/bookings/src/test}/desk-form-details.component.spec.ts (97%) rename {apps/workplace/src/tests/book => libs/bookings/src/test}/parking-request-form-details.component.spec.ts (99%) rename {apps/workplace/src/tests/book/parking-request-flow => libs/bookings/src/test}/parking-request-special-needs.spec.ts (91%) rename {apps/workplace/src/app/components => libs/components/src/lib}/fullscreen-embed.component.ts (89%) rename {apps/workplace/src/tests/components => libs/components/src/tests}/fullscreen-embed.component.spec.ts (89%) rename {apps/map-kiosk/src/app => libs/explore/src/lib}/accessibility-controls.component.ts (100%) rename {apps/map-kiosk/src/app => libs/explore/src/lib}/explore.component.ts (92%) rename {apps/map-kiosk => libs/explore}/src/tests/accessibility-controls.component.spec.ts (98%) create mode 100644 libs/explore/src/tests/explore.component.spec.ts diff --git a/apps/concierge/src/app/desks/desk-book-modal.component.ts b/apps/concierge/src/app/desks/desk-book-modal.component.ts index 7af4ed5158a..1e81b58ab9e 100644 --- a/apps/concierge/src/app/desks/desk-book-modal.component.ts +++ b/apps/concierge/src/app/desks/desk-book-modal.component.ts @@ -1,6 +1,9 @@ import { Component, inject, OnInit, output, signal } from '@angular/core'; import { MatDialogRef } from '@angular/material/dialog'; -import { BookingFormService } from '@placeos/bookings'; +import { + BookingFormService, + NewDeskFormDetailsComponent, +} from '@placeos/bookings'; import { DialogEvent, i18n, @@ -12,8 +15,6 @@ import { TranslatePipe, } from '@placeos/components'; -import { NewDeskFormDetailsComponent } from 'apps/workplace/src/app/book/desk-flow/desk-form-details.component'; - @Component({ selector: 'desk-book-modal', template: ` diff --git a/apps/concierge/src/app/parking/parking-request-modal.component.ts b/apps/concierge/src/app/parking/parking-request-modal.component.ts index 748a3611743..35e0657dcc7 100644 --- a/apps/concierge/src/app/parking/parking-request-modal.component.ts +++ b/apps/concierge/src/app/parking/parking-request-modal.component.ts @@ -3,6 +3,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { approveBooking as approveBookingApi, BookingFormService, + ParkingRequestFormDetailsComponent, ParkingService, } from '@placeos/bookings'; import { @@ -27,7 +28,6 @@ import { TranslatePipe, } from '@placeos/components'; import { roundToNearestMinutes, startOfDay } from 'date-fns'; -import { ParkingRequestFormDetailsComponent } from 'apps/workplace/src/app/book/parking-request-flow/parking-request-form-details.component'; @Component({ selector: 'parking-request-modal', diff --git a/apps/map-kiosk/src/app/app.routes.ts b/apps/map-kiosk/src/app/app.routes.ts index ea77399a647..4e198bbbd69 100644 --- a/apps/map-kiosk/src/app/app.routes.ts +++ b/apps/map-kiosk/src/app/app.routes.ts @@ -18,7 +18,7 @@ export const routes: Routes = [ { path: 'explore', loadComponent: () => - import('./explore.component').then((m) => m.ExploreComponent), + import('./explore.route').then((m) => m.ExploreComponent), canActivate: [AuthorisedUserGuard], }, { diff --git a/apps/map-kiosk/src/app/explore.route.ts b/apps/map-kiosk/src/app/explore.route.ts new file mode 100644 index 00000000000..a861eb8bc0c --- /dev/null +++ b/apps/map-kiosk/src/app/explore.route.ts @@ -0,0 +1,3 @@ +// Route entry for the lazy explore page. The parking page imports +// `@placeos/explore` statically, so the route must not import the lib itself. +export { ExploreComponent } from '@placeos/explore'; diff --git a/apps/visitor-kiosk/src/app/explore.routes.ts b/apps/visitor-kiosk/src/app/explore.routes.ts index 99b703799cc..e27b6b60c4a 100644 --- a/apps/visitor-kiosk/src/app/explore.routes.ts +++ b/apps/visitor-kiosk/src/app/explore.routes.ts @@ -1,8 +1,19 @@ +import { Component } from '@angular/core'; import { Routes } from '@angular/router'; +import { ExploreComponent } from '@placeos/explore'; -import { ExploreComponent } from 'apps/map-kiosk/src/app/explore.component'; +/** + * Explore view for the visitor kiosk. The app returns to the welcome page + * when idle, so the explore view does not run its own reset timer. + */ +@Component({ + selector: 'visitor-kiosk-explore', + template: `
`, + imports: [ExploreComponent], +}) +export class VisitorKioskExploreComponent {} export const ROUTES: Routes = [ - { path: '', component: ExploreComponent }, - { path: ':search_type', component: ExploreComponent }, + { path: '', component: VisitorKioskExploreComponent }, + { path: ':search_type', component: VisitorKioskExploreComponent }, ]; diff --git a/apps/workplace/src/app/book/desk-flow/desk-flow-form.component.ts b/apps/workplace/src/app/book/desk-flow/desk-flow-form.component.ts index 59e20c1a123..f605f8da1ac 100644 --- a/apps/workplace/src/app/book/desk-flow/desk-flow-form.component.ts +++ b/apps/workplace/src/app/book/desk-flow/desk-flow-form.component.ts @@ -5,7 +5,10 @@ import { } from '@angular/material/bottom-sheet'; import { MatRippleModule } from '@angular/material/core'; import { Router } from '@angular/router'; -import { BookingFormService } from '@placeos/bookings'; +import { + BookingFormService, + NewDeskFormDetailsComponent, +} from '@placeos/bookings'; import { getInvalidSignalFields, i18n, @@ -15,7 +18,6 @@ import { import { IconComponent, TranslatePipe } from '@placeos/components'; import { isBefore, startOfMinute } from 'date-fns'; import { NewDeskFlowConfirmComponent } from './desk-flow-confirm.component'; -import { NewDeskFormDetailsComponent } from './desk-form-details.component'; @Component({ selector: 'desk-flow-form', diff --git a/apps/workplace/src/app/book/parking-request-flow/parking-request-form.component.ts b/apps/workplace/src/app/book/parking-request-flow/parking-request-form.component.ts index f752ea07c55..4a867791544 100644 --- a/apps/workplace/src/app/book/parking-request-flow/parking-request-form.component.ts +++ b/apps/workplace/src/app/book/parking-request-flow/parking-request-form.component.ts @@ -4,7 +4,11 @@ import { MatRippleModule } from '@angular/material/core'; import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; -import { BookingFormService, ParkingService } from '@placeos/bookings'; +import { + BookingFormService, + ParkingRequestFormDetailsComponent, + ParkingService, +} from '@placeos/bookings'; import { AsyncHandler, currentUser, @@ -24,7 +28,6 @@ import { } from '@placeos/components'; import { roundToNearestMinutes, startOfDay } from 'date-fns'; import { notifyInvalidBookingFields } from '../booking-invalid-fields'; -import { ParkingRequestFormDetailsComponent } from './parking-request-form-details.component'; @Component({ selector: 'parking-request-form', diff --git a/apps/workplace/src/app/components/virtual-concierge-button.component.ts b/apps/workplace/src/app/components/virtual-concierge-button.component.ts index 5b495eb3d26..a0e1e0dd14d 100644 --- a/apps/workplace/src/app/components/virtual-concierge-button.component.ts +++ b/apps/workplace/src/app/components/virtual-concierge-button.component.ts @@ -2,8 +2,11 @@ import { Component, computed, inject } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatDialog } from '@angular/material/dialog'; import { settingSignal } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { FullscreenEmbedComponent } from './fullscreen-embed.component'; +import { + FullscreenEmbedComponent, + IconComponent, + TranslatePipe, +} from '@placeos/components'; import { SidebarEmbedComponent } from './sidebar-embed.component'; @Component({ diff --git a/apps/workplace/src/tests/book/desk-flow/desk-flow-form.component.spec.ts b/apps/workplace/src/tests/book/desk-flow/desk-flow-form.component.spec.ts index 3563765b0da..f339f7d223d 100644 --- a/apps/workplace/src/tests/book/desk-flow/desk-flow-form.component.spec.ts +++ b/apps/workplace/src/tests/book/desk-flow/desk-flow-form.component.spec.ts @@ -5,12 +5,14 @@ import { createRoutingFactory, SpectatorRouting, } from '@ngneat/spectator/vitest'; -import { BookingFormService } from '@placeos/bookings'; +import { + BookingFormService, + NewDeskFormDetailsComponent, +} from '@placeos/bookings'; import { OrganisationService, setNotifyOutlet } from '@placeos/common'; import { MockComponent, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; import { NewDeskFlowFormComponent } from '../../../app/book/desk-flow/desk-flow-form.component'; -import { NewDeskFormDetailsComponent } from '../../../app/book/desk-flow/desk-form-details.component'; describe('NewDeskFlowFormComponent', () => { let spectator: SpectatorRouting; diff --git a/apps/workplace/src/tests/book/parking-request-form.component.spec.ts b/apps/workplace/src/tests/book/parking-request-form.component.spec.ts index d6d91f75e4d..07006efb0c8 100644 --- a/apps/workplace/src/tests/book/parking-request-form.component.spec.ts +++ b/apps/workplace/src/tests/book/parking-request-form.component.spec.ts @@ -4,14 +4,17 @@ import { ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { BookingFormService, ParkingService } from '@placeos/bookings'; +import { + BookingFormService, + ParkingRequestFormDetailsComponent, + ParkingService, +} from '@placeos/bookings'; import { OrganisationService, settingSignal, SettingsService, } from '@placeos/common'; import { MockProvider } from 'ng-mocks'; -import { ParkingRequestFormDetailsComponent } from '../../app/book/parking-request-flow/parking-request-form-details.component'; import { ParkingRequestFormComponent } from '../../app/book/parking-request-flow/parking-request-form.component'; describe('ParkingRequestFormComponent', () => { diff --git a/apps/workplace/src/tests/components/virtual-concierge-button.component.spec.ts b/apps/workplace/src/tests/components/virtual-concierge-button.component.spec.ts index 1b5cc969a35..ba13f8c8b25 100644 --- a/apps/workplace/src/tests/components/virtual-concierge-button.component.spec.ts +++ b/apps/workplace/src/tests/components/virtual-concierge-button.component.spec.ts @@ -1,10 +1,10 @@ import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MatDialog } from '@angular/material/dialog'; import { settingSignal } from '@placeos/common'; +import { FullscreenEmbedComponent } from '@placeos/components'; import { MockProvider } from 'ng-mocks'; import { VirtualConciergeButtonComponent } from '../../app/components/virtual-concierge-button.component'; -import { FullscreenEmbedComponent } from '../../app/components/fullscreen-embed.component'; import { SidebarEmbedComponent } from '../../app/components/sidebar-embed.component'; describe('VirtualConciergeButtonComponent', () => { diff --git a/docs/settings/visitor-kiosk.md b/docs/settings/visitor-kiosk.md index 0e5b6d6970f..41a6e4f749d 100644 --- a/docs/settings/visitor-kiosk.md +++ b/docs/settings/visitor-kiosk.md @@ -11,7 +11,7 @@ Set settings in Backoffice zone metadata under `visitor-kiosk_app` for the stand | `name` | string | `Visitor-Kiosk` | Application name sent with PlaceOS API requests. If you do not set it, the app uses `short_name`. | | `short_name` | string | `KIOSK` | Prefix for console log messages. The app also uses it as the API application name when `name` is not set. | | `default_route` | string | – | Route the app redirects to after it initialises. Defaults to `welcome` when unset. The Concierge form keeps this value but does not show a field for it. | -| `inactivity_timeout_secs` | number | – | Number of **seconds** with no touch, click or key press before the kiosk returns to the welcome page. This stops the camera and removes visitor details from the screen. The welcome and setup pages stay open. Set to `0` to turn off the reset. Defaults to `120` when unset. The explore page uses the same setting to reset the map, but its fallback is `180` seconds. `0` also turns off the map reset. | +| `inactivity_timeout_secs` | number | – | Number of **seconds** with no touch, click or key press before the kiosk returns to the welcome page. This stops the camera and removes visitor details from the screen. The welcome and setup pages stay open. Set to `0` to turn off the reset. Defaults to `120` when unset. The explore page also returns to the welcome page with this setting. | | `use_24_hour_time` | boolean | – | Show times with a 24-hour clock on the check-in result page and the explore booking form. When not set, the app uses a 12-hour clock. | | `logo_light` | string | `assets/logo-light.svg` | URL of the logo shown when the app is using a light theme. | | `logo_dark` | string | `assets/logo-dark.svg` | URL of the logo shown when the app is using a dark theme. | diff --git a/libs/bookings/src/index.ts b/libs/bookings/src/index.ts index c1018809e7f..635ed455e64 100644 --- a/libs/bookings/src/index.ts +++ b/libs/bookings/src/index.ts @@ -6,6 +6,7 @@ export * from './lib/booking-form.types'; export * from './lib/booking-group.utilities'; export * from './lib/booking.utilities'; export * from './lib/bookings.fn'; +export * from './lib/desk-form-details.component'; export * from './lib/desk-list-field.component'; export * from './lib/desk-select-modal/desk-map.component'; export * from './lib/desk-select-modal/desk-select-modal.component'; @@ -14,6 +15,7 @@ export * from './lib/locker-grid.component'; export * from './lib/locker-list-field.component'; export * from './lib/locker-select-modal/locker-select-modal.component'; export * from './lib/locker.class'; +export * from './lib/parking-request-form-details.component'; export * from './lib/parking-select-modal/parking-select-modal.component'; export * from './lib/parking-space-list-field.component'; export * from './lib/parking.service'; diff --git a/apps/workplace/src/app/book/desk-flow/desk-form-details.component.ts b/libs/bookings/src/lib/desk-form-details.component.ts similarity index 95% rename from apps/workplace/src/app/book/desk-flow/desk-form-details.component.ts rename to libs/bookings/src/lib/desk-form-details.component.ts index 75347402fb7..123a951343d 100644 --- a/apps/workplace/src/app/book/desk-flow/desk-form-details.component.ts +++ b/libs/bookings/src/lib/desk-form-details.component.ts @@ -14,14 +14,6 @@ import { FormField } from '@angular/forms/signals'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { AssetListFieldComponent } from '@placeos/assets'; -import { - BookingForm, - BookingFormService, - BookingFormValue, - DeskListFieldComponent, - requiresLocker, -} from '@placeos/bookings'; import { AsyncHandler, BookingRecurrence, @@ -31,16 +23,23 @@ import { settingSignal, SettingsService, } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { - DateFieldComponent, - DurationFieldComponent, - RecurrenceFieldComponent, - TimeFieldComponent, - UserListFieldComponent, - UserSearchFieldComponent, -} from '@placeos/form-fields'; +import { AssetListFieldComponent } from 'libs/assets/src/lib/asset-list-field.component'; +import { IconComponent } from 'libs/components/src/lib/icon.component'; +import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; +import { DateFieldComponent } from 'libs/form-fields/src/lib/date-field.component'; +import { DurationFieldComponent } from 'libs/form-fields/src/lib/duration-field.component'; +import { RecurrenceFieldComponent } from 'libs/form-fields/src/lib/recurrence-field.component'; +import { TimeFieldComponent } from 'libs/form-fields/src/lib/time-field.component'; +import { UserListFieldComponent } from 'libs/form-fields/src/lib/user-list-field.component'; +import { UserSearchFieldComponent } from 'libs/form-fields/src/lib/user-search-field.component'; import { addDays, endOfDay } from 'date-fns'; +import { + BookingForm, + BookingFormValue, + requiresLocker, +} from './booking-form.model'; +import { BookingFormService } from './booking-form.service'; +import { DeskListFieldComponent } from './desk-list-field.component'; @Component({ selector: 'desk-form-details', diff --git a/apps/workplace/src/app/book/parking-request-flow/parking-request-form-details.component.ts b/libs/bookings/src/lib/parking-request-form-details.component.ts similarity index 99% rename from apps/workplace/src/app/book/parking-request-flow/parking-request-form-details.component.ts rename to libs/bookings/src/lib/parking-request-form-details.component.ts index 476037122d7..01854552249 100644 --- a/apps/workplace/src/app/book/parking-request-flow/parking-request-form-details.component.ts +++ b/libs/bookings/src/lib/parking-request-form-details.component.ts @@ -22,13 +22,6 @@ import { MatInputModule } from '@angular/material/input'; import { MatRadioModule } from '@angular/material/radio'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { - bookedResourceList, - BookingForm, - BookingFormValue, - ParkingService, - queryBookings, -} from '@placeos/bookings'; import { AsyncHandler, currentUser, @@ -43,12 +36,13 @@ import { UploadsService, user_group_names, } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { - DateFieldComponent, - UserSearchFieldComponent, -} from '@placeos/form-fields'; import { isMock } from '@placeos/ts-client'; +import { FullscreenEmbedComponent } from 'libs/components/src/lib/fullscreen-embed.component'; +import { IconComponent } from 'libs/components/src/lib/icon.component'; +import { SettingsToggleComponent } from 'libs/components/src/lib/settings-toggle.component'; +import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; +import { DateFieldComponent } from 'libs/form-fields/src/lib/date-field.component'; +import { UserSearchFieldComponent } from 'libs/form-fields/src/lib/user-search-field.component'; import { addDays, addMinutes, @@ -57,8 +51,9 @@ import { startOfDay, startOfWeek, } from 'date-fns'; -import { SettingsToggleComponent } from 'libs/components/src/lib/settings-toggle.component'; -import { FullscreenEmbedComponent } from '../../components/fullscreen-embed.component'; +import { BookingForm, BookingFormValue } from './booking-form.model'; +import { bookedResourceList, queryBookings } from './bookings.fn'; +import { ParkingService } from './parking.service'; interface ParkingRequestShiftOption { id: string; diff --git a/apps/workplace/src/tests/book/desk-flow/desk-form-details.component.spec.ts b/libs/bookings/src/test/desk-form-details.component.spec.ts similarity index 97% rename from apps/workplace/src/tests/book/desk-flow/desk-form-details.component.spec.ts rename to libs/bookings/src/test/desk-form-details.component.spec.ts index 0ab0ae40b82..14ca2fc24e8 100644 --- a/apps/workplace/src/tests/book/desk-flow/desk-form-details.component.spec.ts +++ b/libs/bookings/src/test/desk-form-details.component.spec.ts @@ -1,11 +1,11 @@ import { NO_ERRORS_SCHEMA, signal, SimpleChange } from '@angular/core'; import { By } from '@angular/platform-browser'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { BookingFormService } from '@placeos/bookings'; import { OrganisationService, SettingsService } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; import { MockProvider } from 'ng-mocks'; -import { NewDeskFormDetailsComponent } from '../../../app/book/desk-flow/desk-form-details.component'; +import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; +import { BookingFormService } from '../lib/booking-form.service'; +import { NewDeskFormDetailsComponent } from '../lib/desk-form-details.component'; describe('NewDeskFormDetailsComponent', () => { let spectator: Spectator; diff --git a/apps/workplace/src/tests/book/parking-request-form-details.component.spec.ts b/libs/bookings/src/test/parking-request-form-details.component.spec.ts similarity index 99% rename from apps/workplace/src/tests/book/parking-request-form-details.component.spec.ts rename to libs/bookings/src/test/parking-request-form-details.component.spec.ts index 3d2c55de231..a9ee6857fc7 100644 --- a/apps/workplace/src/tests/book/parking-request-form-details.component.spec.ts +++ b/libs/bookings/src/test/parking-request-form-details.component.spec.ts @@ -1,12 +1,6 @@ import { Injector, signal, WritableSignal } from '@angular/core'; import { ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { - BookingForm, - BookingFormValue, - generateBookingForm, - ParkingService, -} from '@placeos/bookings'; import { OrganisationService, setCurrentUser, @@ -15,7 +9,13 @@ import { } from '@placeos/common'; import { endOfDay, startOfDay } from 'date-fns'; import { MockProvider } from 'ng-mocks'; -import { ParkingRequestFormDetailsComponent } from '../../app/book/parking-request-flow/parking-request-form-details.component'; +import { + BookingForm, + BookingFormValue, + generateBookingForm, +} from '../lib/booking-form.model'; +import { ParkingRequestFormDetailsComponent } from '../lib/parking-request-form-details.component'; +import { ParkingService } from '../lib/parking.service'; vi.mock('@placeos/ts-client', { spy: true }); diff --git a/apps/workplace/src/tests/book/parking-request-flow/parking-request-special-needs.spec.ts b/libs/bookings/src/test/parking-request-special-needs.spec.ts similarity index 91% rename from apps/workplace/src/tests/book/parking-request-flow/parking-request-special-needs.spec.ts rename to libs/bookings/src/test/parking-request-special-needs.spec.ts index c30d2eef8cd..cc255cbffdb 100644 --- a/apps/workplace/src/tests/book/parking-request-flow/parking-request-special-needs.spec.ts +++ b/libs/bookings/src/test/parking-request-special-needs.spec.ts @@ -1,10 +1,11 @@ import { Injector, signal } from '@angular/core'; import { ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { generateBookingForm, ParkingService } from '@placeos/bookings'; import { OrganisationService, SettingsService } from '@placeos/common'; import { MockProvider } from 'ng-mocks'; -import { ParkingRequestFormDetailsComponent } from '../../../app/book/parking-request-flow/parking-request-form-details.component'; +import { generateBookingForm } from '../lib/booking-form.model'; +import { ParkingRequestFormDetailsComponent } from '../lib/parking-request-form-details.component'; +import { ParkingService } from '../lib/parking.service'; vi.mock('@placeos/ts-client', { spy: true }); diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index b61ea8542c9..13ca0c9df09 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -12,6 +12,7 @@ export * from './lib/chat/chat.component'; export * from './lib/confirm-modal.component'; export * from './lib/custom-tooltip.component'; export * from './lib/dynamic-map.component'; +export * from './lib/fullscreen-embed.component'; export * from './lib/fullscreen-modal-shell.component'; export * from './lib/global-banner.component'; export * from './lib/global-loading.component'; diff --git a/apps/workplace/src/app/components/fullscreen-embed.component.ts b/libs/components/src/lib/fullscreen-embed.component.ts similarity index 89% rename from apps/workplace/src/app/components/fullscreen-embed.component.ts rename to libs/components/src/lib/fullscreen-embed.component.ts index 5c8beeeb3ab..fcc41903442 100644 --- a/apps/workplace/src/app/components/fullscreen-embed.component.ts +++ b/libs/components/src/lib/fullscreen-embed.component.ts @@ -1,7 +1,9 @@ import { Component, inject, signal } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; -import { IconComponent, SafePipe, TranslatePipe } from '@placeos/components'; +import { IconComponent } from './icon.component'; +import { SafePipe } from './safe.pipe'; +import { TranslatePipe } from './translate.pipe'; @Component({ selector: 'fullscreen-embed', diff --git a/apps/workplace/src/tests/components/fullscreen-embed.component.spec.ts b/libs/components/src/tests/fullscreen-embed.component.spec.ts similarity index 89% rename from apps/workplace/src/tests/components/fullscreen-embed.component.spec.ts rename to libs/components/src/tests/fullscreen-embed.component.spec.ts index b8411200352..f30ada10c18 100644 --- a/apps/workplace/src/tests/components/fullscreen-embed.component.spec.ts +++ b/libs/components/src/tests/fullscreen-embed.component.spec.ts @@ -1,9 +1,10 @@ import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { IconComponent, SafePipe } from '@placeos/components'; import { MockComponent } from 'ng-mocks'; -import { FullscreenEmbedComponent } from '../../app/components/fullscreen-embed.component'; +import { FullscreenEmbedComponent } from '../lib/fullscreen-embed.component'; +import { IconComponent } from '../lib/icon.component'; +import { SafePipe } from '../lib/safe.pipe'; describe('FullscreenEmbedComponent', () => { let spectator: Spectator; diff --git a/libs/explore/src/index.ts b/libs/explore/src/index.ts index 2e83b664d83..5d97eacccef 100644 --- a/libs/explore/src/index.ts +++ b/libs/explore/src/index.ts @@ -1,4 +1,5 @@ export * from './lib/explore'; +export * from './lib/explore.component'; export * from './lib/explore-lockers.service'; export * from './lib/explore-poi-modal.component'; export * from './lib/explore-state.service'; diff --git a/apps/map-kiosk/src/app/accessibility-controls.component.ts b/libs/explore/src/lib/accessibility-controls.component.ts similarity index 100% rename from apps/map-kiosk/src/app/accessibility-controls.component.ts rename to libs/explore/src/lib/accessibility-controls.component.ts diff --git a/apps/map-kiosk/src/app/explore.component.ts b/libs/explore/src/lib/explore.component.ts similarity index 92% rename from apps/map-kiosk/src/app/explore.component.ts rename to libs/explore/src/lib/explore.component.ts index bb5ac594aab..a41f9d96f15 100644 --- a/apps/map-kiosk/src/app/explore.component.ts +++ b/libs/explore/src/lib/explore.component.ts @@ -4,6 +4,7 @@ import { effect, HostListener, inject, + input, OnInit, signal, untracked, @@ -29,27 +30,25 @@ import { themedLogo, User, } from '@placeos/common'; -import { - AuthenticatedImageDirective, - CustomTooltipComponent, - DynamicMapComponent, - IconComponent, - MapPinComponent, - MapRadiusComponent, - VirtualKeyboardComponent, -} from '@placeos/components'; -import { SpacePipe, SpacesService } from '@placeos/events'; -import { - ExploreDesksService, - ExploreParkingService, - ExploreSearchComponent, - ExploreSpacesService, - ExploreStateService, - ExploreZonesService, -} from '@placeos/explore'; import { getModule } from '@placeos/ts-client'; -import { MapLocation, showStaff } from '@placeos/users'; +import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; +import { CustomTooltipComponent } from 'libs/components/src/lib/custom-tooltip.component'; +import { DynamicMapComponent } from 'libs/components/src/lib/dynamic-map.component'; +import { IconComponent } from 'libs/components/src/lib/icon.component'; +import { MapPinComponent } from 'libs/components/src/lib/map-pin.component'; +import { MapRadiusComponent } from 'libs/components/src/lib/map-radius.component'; +import { VirtualKeyboardComponent } from 'libs/components/src/lib/virtual-keyboard.component'; +import { SpacePipe } from 'libs/events/src/lib/space.pipe'; +import { SpacesService } from 'libs/events/src/lib/spaces.service'; +import { MapLocation } from 'libs/users/src/lib/location.class'; +import { showStaff } from 'libs/users/src/lib/staff.fn'; import { AccessibilityControlsComponent } from './accessibility-controls.component'; +import { ExploreDesksService } from './explore-desks.service'; +import { ExploreParkingService } from './explore-parking.service'; +import { ExploreSearchComponent } from './explore-search.component'; +import { ExploreSpacesService } from './explore-spaces.service'; +import { ExploreStateService } from './explore-state.service'; +import { ExploreZonesService } from './explore-zones.service'; @Component({ selector: '[app-explore]', @@ -310,6 +309,11 @@ export class ExploreComponent extends AsyncHandler implements OnInit { private _router = inject(Router); private _space_pipe = inject(SpacePipe); + /** + * Whether this view resets the kiosk after `reset_delay` seconds with no + * user action. Set to `false` when the app has its own idle reset. + */ + public readonly reset_when_idle = input(true); /** Number of seconds after a user action to reset the kiosk state. `0` turns the reset off. */ public reset_delay = 180; public readonly show_levels = signal(true); @@ -557,9 +561,13 @@ export class ExploreComponent extends AsyncHandler implements OnInit { }); } - /** Restart the reset timer. A `reset_delay` of `0` or less turns the reset off. */ + /** + * Restart the reset timer. A `reset_delay` of `0` or less, or a + * `reset_when_idle` of `false`, turns the reset off. + */ private _restartReset() { - if (!(this.reset_delay > 0)) return this.clearTimeout('reset'); + if (!this.reset_when_idle() || !(this.reset_delay > 0)) + return this.clearTimeout('reset'); this.timeout('reset', () => this.resetKiosk(), this.reset_delay * 1000); } diff --git a/apps/map-kiosk/src/tests/accessibility-controls.component.spec.ts b/libs/explore/src/tests/accessibility-controls.component.spec.ts similarity index 98% rename from apps/map-kiosk/src/tests/accessibility-controls.component.spec.ts rename to libs/explore/src/tests/accessibility-controls.component.spec.ts index 0ba5f110e15..89a98b43cb7 100644 --- a/apps/map-kiosk/src/tests/accessibility-controls.component.spec.ts +++ b/libs/explore/src/tests/accessibility-controls.component.spec.ts @@ -5,7 +5,7 @@ import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { SettingsService } from '@placeos/common'; import { MockModule, MockProvider } from 'ng-mocks'; -import { AccessibilityControlsComponent } from '../app/accessibility-controls.component'; +import { AccessibilityControlsComponent } from '../lib/accessibility-controls.component'; const setting_signals: Record> = { allow_dark_mode: signal(false), diff --git a/libs/explore/src/tests/explore.component.spec.ts b/libs/explore/src/tests/explore.component.spec.ts new file mode 100644 index 00000000000..8301b1d83aa --- /dev/null +++ b/libs/explore/src/tests/explore.component.spec.ts @@ -0,0 +1,88 @@ +import { signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { OrganisationService, SettingsService } from '@placeos/common'; +import { MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; + +import { SpacePipe } from 'libs/events/src/lib/space.pipe'; +import { SpacesService } from 'libs/events/src/lib/spaces.service'; +import { ExploreDesksService } from '../lib/explore-desks.service'; +import { ExploreParkingService } from '../lib/explore-parking.service'; +import { ExploreSpacesService } from '../lib/explore-spaces.service'; +import { ExploreStateService } from '../lib/explore-state.service'; +import { ExploreZonesService } from '../lib/explore-zones.service'; +import { ExploreComponent } from '../lib/explore.component'; + +describe('ExploreComponent', () => { + let spectator: Spectator; + const createComponent = createComponentFactory({ + component: ExploreComponent, + detectChanges: false, + overrideComponents: [ + [ + ExploreComponent, + { + set: { + template: '', + imports: [], + providers: [ + MockProvider(ExploreSpacesService), + MockProvider(ExploreDesksService), + MockProvider(ExploreZonesService), + MockProvider(ExploreParkingService), + MockProvider(SpacePipe), + ], + }, + }, + ], + ], + providers: [ + MockProvider(ExploreStateService, { + level: signal(''), + positions: { zoom: 1, center: { x: 0.5, y: 0.5 } }, + setLevel: vi.fn(), + setFeatures: vi.fn(), + setPositions: vi.fn(), + } as never), + MockProvider(SettingsService, { + get: vi.fn(), + signal: vi.fn(() => signal(undefined)), + }), + MockProvider(OrganisationService, { + active_region: signal(null), + active_building: signal(null), + buildings: [], + levelsForBuilding: vi.fn(() => []), + } as never), + MockProvider(SpacesService, { initialised: signal(true) }), + MockProvider(MatDialog, { closeAll: vi.fn() }), + MockProvider(Router, { navigate: vi.fn() }), + { + provide: ActivatedRoute, + useValue: { queryParamMap: of(convertToParamMap({})) }, + }, + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + afterEach(() => vi.useRealTimers()); + + it('should return to the start page after the reset delay', () => { + spectator = createComponent(); + spectator.component.onMouse(); + vi.advanceTimersByTime(spectator.component.reset_delay * 1000); + expect(spectator.inject(Router).navigate).toHaveBeenCalledWith(['/']); + }); + + it('should not reset when reset_when_idle is false', () => { + spectator = createComponent({ props: { reset_when_idle: false } }); + spectator.component.onMouse(); + vi.advanceTimersByTime(spectator.component.reset_delay * 1000); + expect(spectator.inject(Router).navigate).not.toHaveBeenCalled(); + }); +}); From a4b63309fe70ca7bc9a78e3ecb9fbc8bc4c5be25 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 17:07:53 +1100 Subject: [PATCH 07/21] refactor(concierge): share app settings load, save, fields and menu The settings modals share their layered load and save. settings-select replaces 63 copies of the same select field. One App Settings menu and openAppSettings() replace the copies in the zone, building, region and level lists. --- .../auto-release-settings-modal.component.ts | 5 +- .../building-list.component.ts | 97 +- .../induction-settings-modal.component.ts | 22 +- .../item-list-modal.component.ts | 17 +- .../app/level-manager/level-list.component.ts | 11 +- .../region-manager/region-list.component.ts | 97 +- .../app-settings-menu.component.ts | 105 ++ .../ui/app-settings/app-settings.utilities.ts | 128 ++ ...concierge-settings-form-modal.component.ts | 1279 ++++------------- .../settings-currency-select.component.ts | 87 ++ .../app-settings/settings-select.component.ts | 68 + ...tor-kiosk-settings-form-modal.component.ts | 318 ++-- ...workplace-settings-form-modal.component.ts | 1135 +++------------ .../app/visitors/guest-listing.component.ts | 9 +- .../zone-manager/zone-manager.component.ts | 90 +- .../building-list.component.spec.ts | 13 - .../level-list.component.spec.ts | 2 +- .../region-list.component.spec.ts | 13 - .../app-settings-menu.component.spec.ts | 59 + .../app-settings.utilities.spec.ts | 164 +++ ...erge-settings-form-modal.component.spec.ts | 37 - ...settings-currency-select.component.spec.ts | 66 + .../settings-select.component.spec.ts | 78 + ...iosk-settings-form-modal.component.spec.ts | 32 - ...lace-settings-form-modal.component.spec.ts | 73 - .../zone-manager.component.spec.ts | 12 - 26 files changed, 1378 insertions(+), 2639 deletions(-) create mode 100644 apps/concierge/src/app/ui/app-settings/app-settings-menu.component.ts create mode 100644 apps/concierge/src/app/ui/app-settings/settings-currency-select.component.ts create mode 100644 apps/concierge/src/app/ui/app-settings/settings-select.component.ts create mode 100644 apps/concierge/src/tests/ui/app-settings/app-settings-menu.component.spec.ts create mode 100644 apps/concierge/src/tests/ui/app-settings/settings-currency-select.component.spec.ts create mode 100644 apps/concierge/src/tests/ui/app-settings/settings-select.component.spec.ts diff --git a/apps/concierge/src/app/building-manager/auto-release-settings-modal.component.ts b/apps/concierge/src/app/building-manager/auto-release-settings-modal.component.ts index a0720ea1c47..7931b5e0a1e 100644 --- a/apps/concierge/src/app/building-manager/auto-release-settings-modal.component.ts +++ b/apps/concierge/src/app/building-manager/auto-release-settings-modal.component.ts @@ -50,6 +50,7 @@ import { } from '@placeos/form-fields'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { appMetadataKey } from '../ui/app-settings/app-settings.utilities'; import { errorText } from '../ui/modal-actions'; @Component({ @@ -460,9 +461,7 @@ export class AutoReleaseSettingsModalComponent implements OnInit { ? await updateSettings(unencrypted.id, unencrypted) : await addSettings(unencrypted); - const metadata_key = - this._settings.get('app.workplace_metadata_key') || - 'workplace_app'; + const metadata_key = appMetadataKey(this._settings, 'workplace'); const metadata = await showMetadata(this.id, metadata_key); const details: any = metadata.details || {}; details.auto_release = new_settings; diff --git a/apps/concierge/src/app/building-manager/building-list.component.ts b/apps/concierge/src/app/building-manager/building-list.component.ts index ef17314e212..fb919081c10 100644 --- a/apps/concierge/src/app/building-manager/building-list.component.ts +++ b/apps/concierge/src/app/building-manager/building-list.component.ts @@ -1,7 +1,7 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { Component, inject } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { i18n, notifySuccess } from '@placeos/common'; +import { AppSettingsMenuComponent } from '../ui/app-settings/app-settings-menu.component'; import { BuildingManagementService } from './building-management.service'; import { MatMenuModule } from '@angular/material/menu'; @@ -87,7 +87,7 @@ import {
- - - - - - +
- - - - - - + + * + * ``` + */ +@Component({ + selector: 'app-settings-menu', + template: ` + + @for (item of ITEMS; track item.kind) { + + } + + `, + imports: [MatMenuModule, IconComponent, TranslatePipe], +}) +export class AppSettingsMenuComponent { + private _dialog = inject(MatDialog); + + public readonly zone = input.required(); + public readonly menu = viewChild.required(MatMenu); + + public readonly ITEMS: readonly { + kind: AppSettingsKind; + icon: string; + label: string; + }[] = [ + { + kind: 'workplace', + icon: 'meeting_room', + label: 'APP.CONCIERGE.APP_SETTINGS_WORKPLACE', + }, + { + kind: 'concierge', + icon: 'support_agent', + label: 'APP.CONCIERGE.APP_SETTINGS_CONCIERGE', + }, + { + kind: 'booking-panel', + icon: 'event_busy', + label: 'APP.CONCIERGE.APP_SETTINGS_BOOKING_PANEL', + }, + { + kind: 'visitor-kiosk', + icon: 'qr_code', + label: 'APP.CONCIERGE.APP_SETTINGS_VISITOR_KIOSK', + }, + ]; + + public open(kind: AppSettingsKind) { + return openAppSettings(this._dialog, kind, this.zone()); + } +} diff --git a/apps/concierge/src/app/ui/app-settings/app-settings.utilities.ts b/apps/concierge/src/app/ui/app-settings/app-settings.utilities.ts index ae82523ffa6..c26f89595ec 100644 --- a/apps/concierge/src/app/ui/app-settings/app-settings.utilities.ts +++ b/apps/concierge/src/app/ui/app-settings/app-settings.utilities.ts @@ -1,3 +1,6 @@ +import { currentUser, SettingsService, VERSION } from '@placeos/common'; +import { PlaceZone, showMetadata, updateMetadata } from '@placeos/ts-client'; + export type AppSettings = Record; function isSettingsGroup(value: unknown): value is AppSettings { @@ -178,3 +181,128 @@ export function appSettingOverrides( } return overrides; } + +/** Zone fields that the App Settings modals read. The organisation fits too. */ +export type AppSettingsZone = Pick & + Partial>; + +/** Applications that store their settings in zone metadata. */ +export type MetadataApp = 'concierge' | 'workplace' | 'visitor-kiosk'; + +/** Settings checked in order for each app's metadata key, then the default. */ +const APP_METADATA_KEYS: Record< + MetadataApp, + { settings: readonly string[]; fallback: string } +> = { + concierge: { + settings: ['app.concierge_metadata_key', 'app.concierge_app'], + fallback: 'concierge_app', + }, + workplace: { + settings: ['app.workplace_metadata_key'], + fallback: 'workplace_app', + }, + 'visitor-kiosk': { + settings: ['app.visitor_kiosk_metadata_key', 'app.visitor_kiosk_app'], + fallback: 'visitor-kiosk_app', + }, +}; + +/** Name of the zone metadata that holds the settings of `app`. */ +export function appMetadataKey( + settings: SettingsService, + app: MetadataApp, +): string { + const { settings: keys, fallback } = APP_METADATA_KEYS[app]; + for (const key of keys) { + const value = settings.get(key); + if (value) return value; + } + return fallback; +} + +/** Modal heading: the title and a badge with the zone name. */ +export function appSettingsHeading( + title: string, + zone: AppSettingsZone, +): string { + return `${title}
${zone.display_name || zone.name || 'Organisation'}
`; +} + +/** Settings layers that an App Settings modal loads for a zone. */ +export interface AppSettingsLayers { + /** Application defaults, then organisation and parent zone settings. */ + inherited: AppSettings; + /** Settings stored on the zone. */ + stored: AppSettings; +} + +async function loadMetadata(id: string, key: string): Promise { + const metadata = await showMetadata(id, key); + return (metadata.details || {}) as AppSettings; +} + +/** + * Load the `key` metadata of `zone` and of the zones it inherits from. + * The organisation is skipped when it is the zone, and the parent zone is + * skipped when it is the organisation. Rejects when a request fails. + */ +export async function loadLayeredAppSettings( + zone: AppSettingsZone, + org_id: string, + key: string, + defaults: AppSettings, +): Promise { + const is_org = zone.id === org_id; + const org = is_org ? {} : await loadMetadata(org_id, key); + const parent = + !is_org && zone.parent_id && zone.parent_id !== org_id + ? await loadMetadata(zone.parent_id, key) + : {}; + const stored = await loadMetadata(zone.id, key); + return { inherited: mergeAppSettings(defaults, org, parent), stored }; +} + +/** Current user details saved with each settings change. */ +function editedBy(): AppSettings { + const user = currentUser(); + return { + id: user.id, + name: user.name, + email: user.email, + domain: location.hostname, + role: user.groups.includes('placeos_admin') + ? 'Admin' + : user.groups.includes('placeos_support') + ? 'Support' + : 'User', + }; +} + +/** + * Save the form `values` as the `key` metadata of `zone_id`. Only the + * overrides of `layers.inherited` are saved (see `appSettingOverrides`), + * with an `edited_by` stamp for the current user. `initial` holds the form + * values right after loading. `app_name` goes in the metadata description. + */ +export async function saveAppSettings( + zone_id: string, + key: string, + app_name: string, + layers: AppSettingsLayers, + initial: AppSettings, + values: AppSettings, +): Promise { + const details = appSettingOverrides( + mergeAppSettings(layers.stored, values), + layers.inherited, + initial, + layers.stored, + ); + details.edited_by = editedBy(); + await updateMetadata(zone_id, { + name: key, + details, + description: `[${VERSION.hash}|C] ${app_name} Application Settings`, + }); +} diff --git a/apps/concierge/src/app/ui/app-settings/concierge-settings-form-modal.component.ts b/apps/concierge/src/app/ui/app-settings/concierge-settings-form-modal.component.ts index d26dce38d7e..9561fe1da5e 100644 --- a/apps/concierge/src/app/ui/app-settings/concierge-settings-form-modal.component.ts +++ b/apps/concierge/src/app/ui/app-settings/concierge-settings-form-modal.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { Component, inject, OnInit, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { form, FormField, min } from '@angular/forms/signals'; import { @@ -7,14 +7,12 @@ import { MatDialogRef, } from '@angular/material/dialog'; import { - buildCurrencyOptions, - currentUser, notifyError, notifySuccess, OrganisationService, SettingsService, } from '@placeos/common'; -import { PlaceZone, showMetadata, updateMetadata } from '@placeos/ts-client'; +import { PlaceZone } from '@placeos/ts-client'; import { DEFAULT_SETTINGS } from 'apps/concierge/src/environments/settings'; import { format } from 'date-fns'; @@ -23,7 +21,6 @@ import { MatRippleModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; -import { VERSION } from '@placeos/common'; import { FullscreenModalShellComponent, IconComponent, @@ -34,11 +31,16 @@ import { errorText } from '../modal-actions'; import { applyAppSettings, applyBookingFallbacks, - appSettingOverrides, + appMetadataKey, AppSettings, + appSettingsHeading, + AppSettingsLayers, + loadLayeredAppSettings, LOCKER_BOOKING_FALLBACKS, mergeAppSettings, + saveAppSettings, } from './app-settings.utilities'; +import { SettingsCurrencySelectComponent } from './settings-currency-select.component'; import { AVAILABLE_PERIOD_EXTENDED_OPTIONS, AVAILABLE_PERIOD_SHORT_OPTIONS, @@ -52,13 +54,14 @@ import { MAX_DURATION_FULL_OPTIONS, WEEK_START_OPTIONS, } from './settings-option.constants'; +import { SettingsSelectComponent } from './settings-select.component'; import { UploadButtonComponent } from './upload-button.component'; @Component({ selector: 'concierge-settings-form-modal', template: ` @@ -403,77 +406,18 @@ import { UploadButtonComponent } from './upload-button.component'; />

-
- - - - @for (opt of WEEK_START; track opt.value) { - {{ - opt.label - }} - } - - - Day of the week to show initially on various - calendars - - -
-
- - - - - - - @for ( - option of filtered_currency_options(); - track option.code - ) { - - {{ option.display_name }} - - } - @if (!filtered_currency_options().length) { - - No currencies match your search - - } - - - ISO 4217 currency code for pricing - - -
+ +
-
- - - - @for ( - opt of DAY_TIMELINE_VIEW; - track opt.value - ) { - {{ opt.label }} - } - - - Default day timeline layout for room - bookings - - -
+
-
- - - - @for ( - opt of BLOCK_START; - track opt.value - ) { - {{ - opt.label - }} - } - - - Start time of dayview blocks - - -
-
- - - - @for ( - opt of BLOCK_END; - track opt.value - ) { - {{ - opt.label - }} - } - - - End time of dayview blocks - - -
+ +
-
- - - - @for ( - opt of BLOCK_HEIGHT; - track opt.value - ) { - {{ - opt.label - }} - } - - - Height of 1 hour in dayview - - -
-
- - - - @for ( - opt of MAX_DURATION_FULL; - track opt.value - ) { - {{ - opt.label - }} - } - - - Max duration for single day - bookings - - -
-
-
- - - - No limit - @for ( - opt of AVAILABLE_PERIOD_SHORT; - track opt.value - ) { - {{ opt.label }} - } - - - Number of days ahead users can - create group events - - + +
+
-
- - - - @for ( - opt of BOOKABLE_HOUR_START; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of BOOKABLE_HOUR_END; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
-
- - - - @for ( - opt of BLOCK_START; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of BLOCK_END; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
@@ -1062,72 +750,24 @@ import { UploadButtonComponent } from './upload-button.component';
-
- - - - @for ( - opt of BOOKABLE_HOUR_START; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of BOOKABLE_HOUR_END; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
@@ -1536,71 +1025,20 @@ import { UploadButtonComponent } from './upload-button.component'; [class.open]="shown_group() === 'parking'" >
-
- - - - @for ( - opt of AVAILABLE_PERIOD_EXTENDED; - track opt.value - ) { - {{ opt.label }} - } - - - Number of days ahead the user is - able to book - - -
-
- - - - @for ( - opt of MAX_DURATION_FULL; - track opt.value - ) { - {{ opt.label }} - } - - - Max duration for single day bookings - - -
+ +
-
- - - - @for ( - opt of AVAILABLE_PERIOD_EXTENDED; - track opt.value - ) { - {{ opt.label }} - } - - - Number of days ahead the user is - able to book - - -
-
- - - - @for ( - opt of MAX_DURATION_FULL; - track opt.value - ) { - {{ opt.label }} - } - - -
+ +
-
- - - - @for ( - opt of BOOKABLE_HOUR_START; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of BOOKABLE_HOUR_END; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
= {}; - public old_settings: Record = {}; + private _layers: AppSettingsLayers = { inherited: {}, stored: {} }; /** Form values right after loading, used to find user changes on save. */ - public initial_settings: AppSettings = {}; + private _initial: AppSettings = {}; public readonly zone = this._data.zone; + public readonly heading = appSettingsHeading( + 'Concierge Settings', + this.zone, + ); public readonly AVAILABLE_PERIOD_SHORT = AVAILABLE_PERIOD_SHORT_OPTIONS; public readonly AVAILABLE_PERIOD_EXTENDED = AVAILABLE_PERIOD_EXTENDED_OPTIONS; @@ -2026,22 +1324,9 @@ export class ConciergeSettingsFormModalComponent implements OnInit { public readonly BLOCK_HEIGHT = BLOCK_HEIGHT_OPTIONS; public readonly DAY_TIMELINE_VIEW = DAY_TIMELINE_VIEW_OPTIONS; public readonly BANNER_TYPE = BANNER_TYPE_OPTIONS; - public readonly heading = signal(''); public readonly loading = signal(''); public readonly shown_group = signal(''); - public readonly currency_filter = signal(''); - public readonly currency_options = buildCurrencyOptions(); - public readonly filtered_currency_options = computed(() => { - const filter_text = this.currency_filter().trim().toLowerCase(); - if (!filter_text) return this.currency_options; - return this.currency_options.filter((option) => - option.search_text.includes(filter_text), - ); - }); - public readonly settings_key = - this._settings.get('app.concierge_metadata_key') || - this._settings.get('app.concierge_app') || - 'concierge_app'; + public readonly settings_key = appMetadataKey(this._settings, 'concierge'); public readonly model = signal({ logo_light: '', @@ -2179,39 +1464,27 @@ export class ConciergeSettingsFormModalComponent implements OnInit { } public async ngOnInit() { - const zone = this._data.zone; this.loading.set('Loading existing settings...'); - this.heading.set( - `Concierge Settings
${this.zone.display_name || this.zone.name || 'Organisation'}
`, - ); try { - const org_id = this._org.organisation.id; - const org_metadata = - zone.id !== org_id ? await this._getMetadata(org_id) : {}; - const parent_metadata = - zone.id !== org_id && - !!zone.parent_id && - org_id !== zone.parent_id - ? await this._getMetadata(zone.parent_id) - : {}; - const metadata = await this._getMetadata(zone.id); - this.existing_settings = mergeAppSettings( + this._layers = await loadLayeredAppSettings( + this.zone, + this._org.organisation.id, + this.settings_key, DEFAULT_SETTINGS.app, - org_metadata, - parent_metadata, ); - this._patchModel(this.existing_settings); - this._patchModel(metadata || {}); + const { inherited, stored } = this._layers; this.model.update((model) => applyBookingFallbacks( - model, - mergeAppSettings(this.existing_settings, metadata), + applyAppSettings( + applyAppSettings(model, inherited), + stored, + ), + mergeAppSettings(inherited, stored), 'lockers', LOCKER_BOOKING_FALLBACKS, ), ); - this.old_settings = metadata; - this.initial_settings = this.model(); + this._initial = this.model(); } catch (e) { // Saving defaults over settings that failed to load would lose // them, so close instead. @@ -2226,48 +1499,17 @@ export class ConciergeSettingsFormModalComponent implements OnInit { this.shown_group.update((shown) => (group === shown ? '' : group)); } - public updateCurrencyFilter(value: string) { - this.currency_filter.set((value || '').trim()); - } - - public onCurrencySelectStateChange(is_open: boolean) { - if (!is_open) { - this.currency_filter.set(''); - } - } - public async save() { this.loading.set('Saving settings...'); - const zone = this._data.zone; - const working_settings = mergeAppSettings( - this.old_settings, - this.model(), - ); - const new_settings = appSettingOverrides( - working_settings, - this.existing_settings, - this.initial_settings, - this.old_settings, - ); - - const user = currentUser(); - new_settings.edited_by = { - id: user.id, - name: user.name, - email: user.email, - domain: location.hostname, - role: user.groups.includes('placeos_admin') - ? 'Admin' - : user.groups.includes('placeos_support') - ? 'Support' - : 'User', - }; try { - await updateMetadata(zone.id, { - name: `${this.settings_key}`, - details: new_settings, - description: `[${VERSION.hash}|C] Concierge Application Settings`, - }); + await saveAppSettings( + this.zone.id, + this.settings_key, + 'Concierge', + this._layers, + this._initial, + this.model(), + ); } catch (e) { console.error(e); this.loading.set(''); @@ -2280,13 +1522,4 @@ export class ConciergeSettingsFormModalComponent implements OnInit { notifySuccess('Successfully saved concierge app settings'); this._dialog_ref.close(); } - - private _patchModel(patch: Record) { - this.model.update((model) => applyAppSettings(model, patch)); - } - - private async _getMetadata(id) { - const metadata = await showMetadata(id, this.settings_key); - return (metadata.details || {}) as Record; - } } diff --git a/apps/concierge/src/app/ui/app-settings/settings-currency-select.component.ts b/apps/concierge/src/app/ui/app-settings/settings-currency-select.component.ts new file mode 100644 index 00000000000..fbe32b68603 --- /dev/null +++ b/apps/concierge/src/app/ui/app-settings/settings-currency-select.component.ts @@ -0,0 +1,87 @@ +import { Component, computed, input, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FieldTree, FormField } from '@angular/forms/signals'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { buildCurrencyOptions } from '@placeos/common'; + +/** + * Currency select with a search box, used by the Concierge and Workplace + * App Settings modals. The label gets the id `-label`. + * + * `` + */ +@Component({ + selector: 'settings-currency-select', + template: ` + + + + + + + @for (option of filtered_options(); track option.code) { + + {{ option.display_name }} + + } + @if (!filtered_options().length) { + + No currencies match your search + + } + + ISO 4217 currency code for pricing + + `, + styles: [ + ` + :host { + display: block; + } + `, + ], + imports: [ + FormField, + FormsModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + ], +}) +export class SettingsCurrencySelectComponent { + /** Base id. The label gets `-label`. */ + public readonly field_id = input.required(); + /** Signal forms field that holds the ISO 4217 currency code. */ + public readonly field = input.required>(); + + public readonly filter = signal(''); + public readonly options = buildCurrencyOptions(); + public readonly filtered_options = computed(() => { + const filter_text = this.filter().trim().toLowerCase(); + if (!filter_text) return this.options; + return this.options.filter((option) => + option.search_text.includes(filter_text), + ); + }); + + /** Clear the search when the select panel closes. */ + public onOpenedChange(is_open: boolean) { + if (!is_open) this.filter.set(''); + } +} diff --git a/apps/concierge/src/app/ui/app-settings/settings-select.component.ts b/apps/concierge/src/app/ui/app-settings/settings-select.component.ts new file mode 100644 index 00000000000..76a1d263fa4 --- /dev/null +++ b/apps/concierge/src/app/ui/app-settings/settings-select.component.ts @@ -0,0 +1,68 @@ +import { booleanAttribute, Component, input } from '@angular/core'; +import { Field, FormField } from '@angular/forms/signals'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { SelectOption } from './settings-option.constants'; + +/** + * Labelled select over a list of options, used by the App Settings modals. + * The label gets the id `-label` and names the select. + * + * ```html + * + * ``` + */ +@Component({ + selector: 'settings-select', + template: ` + + + + @if (null_option()) { + {{ null_option() }} + } + @for (opt of options(); track opt.value) { + {{ opt.label }} + } + + @if (hint()) { + {{ hint() }} + } + + `, + styles: [ + ` + :host { + display: block; + } + `, + ], + imports: [FormField, MatFormFieldModule, MatSelectModule], +}) +export class SettingsSelectComponent { + /** Base id. The label gets `-label`. */ + public readonly field_id = input.required(); + public readonly label = input.required(); + /** Signal forms field that the select edits. */ + public readonly field = input.required>(); + public readonly options = input.required(); + public readonly hint = input(''); + public readonly placeholder = input(''); + /** Label of a first option with the value `null`. No option when empty. */ + public readonly null_option = input(''); + /** Select many values. Set it once; Material cannot change it later. */ + public readonly multiple = input(false, { transform: booleanAttribute }); +} diff --git a/apps/concierge/src/app/ui/app-settings/visitor-kiosk-settings-form-modal.component.ts b/apps/concierge/src/app/ui/app-settings/visitor-kiosk-settings-form-modal.component.ts index 663bb1906ce..18a54b18c44 100644 --- a/apps/concierge/src/app/ui/app-settings/visitor-kiosk-settings-form-modal.component.ts +++ b/apps/concierge/src/app/ui/app-settings/visitor-kiosk-settings-form-modal.component.ts @@ -7,19 +7,16 @@ import { MatDialogRef, } from '@angular/material/dialog'; import { - currentUser, notifyError, notifySuccess, OrganisationService, SettingsService, - VERSION, } from '@placeos/common'; -import { PlaceZone, showMetadata, updateMetadata } from '@placeos/ts-client'; +import { PlaceZone } from '@placeos/ts-client'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatSelectModule } from '@angular/material/select'; import { FullscreenModalShellComponent, SettingsToggleComponent, @@ -28,14 +25,17 @@ import { DEFAULT_SETTINGS } from 'apps/visitor-kiosk/src/environments/settings'; import { errorText } from '../modal-actions'; import { applyAppSettings, - appSettingOverrides, + appMetadataKey, AppSettings, - mergeAppSettings, + AppSettingsLayers, + loadLayeredAppSettings, + saveAppSettings, } from './app-settings.utilities'; import { EXPLORE_FEATURE_OPTIONS, MAX_DURATION_MINI_OPTIONS, } from './settings-option.constants'; +import { SettingsSelectComponent } from './settings-select.component'; import { UploadButtonComponent } from './upload-button.component'; /** Device tooltip fields that `explore.hide_device_fields` can hide */ @@ -328,154 +328,62 @@ const DEVICE_FIELD_OPTIONS = [ > PlaceOS Maps

-
- - - - @for (opt of EXPLORE_FEATURE; track opt.value) { - {{ - opt.label - }} - } - - -
+
-
- - - - @for ( - opt of EXPLORE_FEATURE; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of EXPLORE_FEATURE; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
-
- - - - @for ( - opt of EXPLORE_FEATURE; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of EXPLORE_FEATURE; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
-
- - - - @for (opt of DEVICE_FIELDS; track opt.value) { - {{ - opt.label - }} - } - - + +
+
Visitor Booking Rules

-
- - - - @for ( - opt of MAX_DURATION_MINI; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+
= {}; - public old_settings: Record = {}; + private _layers: AppSettingsLayers = { inherited: {}, stored: {} }; /** Form values right after loading, used to find user changes on save. */ - public initial_settings: AppSettings = {}; + private _initial: AppSettings = {}; public readonly loading = signal(''); public readonly zone = this._data.zone; public readonly MAX_DURATION_MINI = MAX_DURATION_MINI_OPTIONS; public readonly EXPLORE_FEATURE = EXPLORE_FEATURE_OPTIONS; public readonly DEVICE_FIELDS = DEVICE_FIELD_OPTIONS; - public readonly settings_key = - this._settings.get('app.visitor_kiosk_metadata_key') || - this._settings.get('app.visitor_kiosk_app') || - 'visitor-kiosk_app'; + public readonly settings_key = appMetadataKey( + this._settings, + 'visitor-kiosk', + ); public readonly model = signal({ logo_light: 'assets/logo-light.svg', @@ -634,28 +525,18 @@ export class VisitorKioskSettingsFormModalComponent implements OnInit { public readonly form = form(this.model); public async ngOnInit() { - const zone = this._data.zone; this.loading.set('Loading existing settings...'); try { - const org_id = this._org.organisation.id; - const org_metadata = - zone.id !== org_id ? await this._getMetadata(org_id) : {}; - const parent_metadata = - zone.id !== org_id && - !!zone.parent_id && - org_id !== zone.parent_id - ? await this._getMetadata(zone.parent_id) - : {}; - const metadata = await this._getMetadata(zone.id); - this.existing_settings = mergeAppSettings( + this._layers = await loadLayeredAppSettings( + this.zone, + this._org.organisation.id, + this.settings_key, DEFAULT_SETTINGS.app, - org_metadata, - parent_metadata, ); - this._patchModel(this.existing_settings); - this._patchModel(metadata || {}); - this.old_settings = metadata; - this.initial_settings = this.model(); + const { inherited, stored } = this._layers; + this._patchModel(inherited); + this._patchModel(stored); + this._initial = this.model(); } catch (e) { // Saving defaults over settings that failed to load would lose // them, so close instead. @@ -680,35 +561,15 @@ export class VisitorKioskSettingsFormModalComponent implements OnInit { public async save() { this.loading.set('Saving settings...'); - const zone = this._data.zone; - const working_settings = mergeAppSettings( - this.old_settings, - this.model(), - ); - const new_settings = appSettingOverrides( - working_settings, - this.existing_settings, - this.initial_settings, - this.old_settings, - ); - const user = currentUser(); - new_settings.edited_by = { - id: user.id, - name: user.name, - email: user.email, - domain: location.hostname, - role: user.groups.includes('placeos_admin') - ? 'Admin' - : user.groups.includes('placeos_support') - ? 'Support' - : 'User', - }; try { - await updateMetadata(zone.id, { - name: `${this.settings_key}`, - details: new_settings, - description: `[${VERSION.hash}|C] Visitor-kiosk Application Settings`, - }); + await saveAppSettings( + this.zone.id, + this.settings_key, + 'Visitor-kiosk', + this._layers, + this._initial, + this.model(), + ); } catch (e) { console.error(e); this.loading.set(''); @@ -721,9 +582,4 @@ export class VisitorKioskSettingsFormModalComponent implements OnInit { notifySuccess('Successfully saved visitor kiosk app settings'); this._dialog_ref.close(); } - - private async _getMetadata(id) { - const metadata = await showMetadata(id, this.settings_key); - return (metadata.details || {}) as Record; - } } diff --git a/apps/concierge/src/app/ui/app-settings/workplace-settings-form-modal.component.ts b/apps/concierge/src/app/ui/app-settings/workplace-settings-form-modal.component.ts index 538a3f15dbf..6699a8da105 100644 --- a/apps/concierge/src/app/ui/app-settings/workplace-settings-form-modal.component.ts +++ b/apps/concierge/src/app/ui/app-settings/workplace-settings-form-modal.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { Component, inject, OnInit, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { form, FormField, validate } from '@angular/forms/signals'; import { @@ -6,11 +6,9 @@ import { MatDialogModule, MatDialogRef, } from '@angular/material/dialog'; -import { PlaceZone, showMetadata, updateMetadata } from '@placeos/ts-client'; +import { PlaceZone } from '@placeos/ts-client'; import { - buildCurrencyOptions, - currentUser, notifyError, notifySuccess, OrganisationService, @@ -25,7 +23,6 @@ import { MatRippleModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; -import { VERSION } from '@placeos/common'; import { FullscreenModalShellComponent, IconComponent, @@ -35,10 +32,14 @@ import { import { applyAppSettings, applyBookingFallbacks, - appSettingOverrides, + appMetadataKey, AppSettings, + appSettingsHeading, + AppSettingsLayers, + loadLayeredAppSettings, LOCKER_BOOKING_FALLBACKS, mergeAppSettings, + saveAppSettings, } from './app-settings.utilities'; import { AVAILABLE_PERIOD_EXTENDED_OPTIONS, @@ -53,6 +54,8 @@ import { SETUP_BREAKDOWN_OPTIONS, WEEK_START_OPTIONS, } from './settings-option.constants'; +import { SettingsCurrencySelectComponent } from './settings-currency-select.component'; +import { SettingsSelectComponent } from './settings-select.component'; import { UploadButtonComponent } from './upload-button.component'; import { errorText } from '../modal-actions'; @@ -60,7 +63,7 @@ import { errorText } from '../modal-actions'; selector: 'workplace-settings-form-modal', template: ` @@ -342,69 +345,18 @@ import { errorText } from '../modal-actions';
-
- - - - @for (opt of WEEK_START; track opt.value) { - {{ - opt.label - }} - } - - - Day of the week to show initially on various - calendars - - -
-
- - - - - - - @for ( - option of filtered_currency_options(); - track option.code - ) { - - {{ option.display_name }} - - } - @if (!filtered_currency_options().length) { - - No currencies match your search - - } - - - ISO 4217 currency code for pricing - - -
+ +
@@ -569,34 +521,13 @@ import { errorText } from '../modal-actions'; [class.open]="shown_group() === 'spaces'" >
-
- - - - @for ( - opt of AVAILABLE_PERIOD_EXTENDED; - track opt.value - ) { - {{ opt.label }} - } - - - Number of days ahead the user is - able to book - - -
+
-
- - - - @for ( - opt of MAX_DURATION_FULL; - track opt.value - ) { - {{ opt.label }} - } - - - Max duration for single day bookings - - -
+
-
- - - - @for ( - opt of SETUP_BREAKDOWN; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of SETUP_BREAKDOWN; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
-
- - - - @for ( - opt of BOOKABLE_HOUR_START; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of BOOKABLE_HOUR_END; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
-
- - - - @for ( - opt of BLOCK_START; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of BLOCK_END; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
@@ -935,113 +726,34 @@ import { errorText } from '../modal-actions'; [class.open]="shown_group() === 'desks'" >
-
- - - - @for ( - opt of MAX_DURATION_SHORT; - track opt.value - ) { - {{ opt.label }} - } - - -
-
- - - - @for ( - opt of AVAILABLE_PERIOD_EXTENDED; - track opt.value - ) { - {{ opt.label }} - } - - - Number of days ahead the user is - able to book - - -
+ +
-
- - - - @for ( - opt of BOOKABLE_HOUR_START; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of BOOKABLE_HOUR_END; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
-
- - - - @for ( - opt of BOOKABLE_HOUR_START; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of BOOKABLE_HOUR_END; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
-
- - - - @for ( - opt of BLOCK_START; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of BLOCK_END; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
@@ -1347,147 +979,53 @@ import { errorText } from '../modal-actions'; [class.open]="shown_group() === 'explore'" >
-
- - - - @for ( - opt of EXPLORE_FEATURE; - track opt.value - ) { - {{ opt.label }} - } - - -
+
-
- - - - @for ( - opt of EXPLORE_FEATURE; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of EXPLORE_FEATURE; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
-
- - - - @for ( - opt of EXPLORE_FEATURE; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of EXPLORE_FEATURE; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
@if (model().explore?.show_legend) {
-
- - - - @for ( - opt of MAX_DURATION_SHORT; - track opt.value - ) { - {{ opt.label }} - } - - -
-
- - - - @for ( - opt of AVAILABLE_PERIOD_EXTENDED; - track opt.value - ) { - {{ opt.label }} - } - - - Number of days ahead the user is - able to book - - -
+ +
-
- - - - @for ( - opt of BOOKABLE_HOUR_START; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of BOOKABLE_HOUR_END; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
-
- - - - @for ( - opt of MAX_DURATION_SHORT; - track opt.value - ) { - {{ opt.label }} - } - - -
+
-
- - - - @for ( - opt of BOOKABLE_HOUR_START; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of BOOKABLE_HOUR_END; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
= {}; - public old_settings: Record = {}; + private _layers: AppSettingsLayers = { inherited: {}, stored: {} }; /** Form values right after loading, used to find user changes on save. */ - public initial_settings: AppSettings = {}; + private _initial: AppSettings = {}; public readonly loading = signal(''); public readonly shown_group = signal(''); - public readonly currency_filter = signal(''); - public readonly currency_options = buildCurrencyOptions(); - public readonly filtered_currency_options = computed(() => { - const filter_text = this.currency_filter().trim().toLowerCase(); - if (!filter_text) return this.currency_options; - return this.currency_options.filter((option) => - option.search_text.includes(filter_text), - ); - }); public readonly zone = this._data.zone; - public readonly settings_key = - this._settings.get('app.workplace_metadata_key') || 'workplace_app'; + public readonly heading = appSettingsHeading('Workplace Settings', this.zone); + public readonly settings_key = appMetadataKey(this._settings, 'workplace'); public readonly AVAILABLE_PERIOD_EXTENDED = AVAILABLE_PERIOD_EXTENDED_OPTIONS; @@ -2220,39 +1611,24 @@ export class WorkplaceSettingsFormModalComponent implements OnInit { } public async ngOnInit() { - const zone = this._data.zone; this.loading.set('Loading existing settings...'); - this.heading.set( - `Workplace Settings
${this.zone.display_name || this.zone.name || 'Organisation'}
`, - ); try { - const org_id = this._org.organisation.id; - const org_metadata = - zone.id !== org_id ? await this._getMetadata(org_id) : {}; - const parent_metadata = - zone.id !== org_id && - !!zone.parent_id && - org_id !== zone.parent_id - ? await this._getMetadata(zone.parent_id) - : {}; - const metadata = await this._getMetadata(zone.id); - this.existing_settings = mergeAppSettings( + this._layers = await loadLayeredAppSettings( + this.zone, + this._org.organisation.id, + this.settings_key, DEFAULT_SETTINGS.app, - org_metadata, - parent_metadata, ); - this._patchModel(this.existing_settings); - this._patchModel(metadata || {}); + const { inherited, stored } = this._layers; this.model.update((model) => applyBookingFallbacks( - model, - mergeAppSettings(this.existing_settings, metadata), + applyAppSettings(applyAppSettings(model, inherited), stored), + mergeAppSettings(inherited, stored), 'lockers', LOCKER_BOOKING_FALLBACKS, ), ); - this.old_settings = metadata; - this.initial_settings = this.model(); + this._initial = this.model(); } catch (e) { // Saving defaults over settings that failed to load would lose // them, so close instead. @@ -2267,47 +1643,17 @@ export class WorkplaceSettingsFormModalComponent implements OnInit { this.shown_group.update((shown) => (group === shown ? '' : group)); } - public updateCurrencyFilter(value: string) { - this.currency_filter.set((value || '').trim()); - } - - public onCurrencySelectStateChange(is_open: boolean) { - if (!is_open) { - this.currency_filter.set(''); - } - } - public async save() { this.loading.set('Saving settings...'); - const zone = this._data.zone; - const working_settings = mergeAppSettings( - this.old_settings, - this.model(), - ); - const new_settings = appSettingOverrides( - working_settings, - this.existing_settings, - this.initial_settings, - this.old_settings, - ); - const user = currentUser(); - new_settings.edited_by = { - id: user.id, - name: user.name, - email: user.email, - domain: location.hostname, - role: user.groups.includes('placeos_admin') - ? 'Admin' - : user.groups.includes('placeos_support') - ? 'Support' - : 'User', - }; try { - await updateMetadata(zone.id, { - name: `${this.settings_key}`, - details: new_settings, - description: `[${VERSION.hash}|C] Workplace Application Settings`, - }); + await saveAppSettings( + this.zone.id, + this.settings_key, + 'Workplace', + this._layers, + this._initial, + this.model(), + ); } catch (e) { console.error(e); this.loading.set(''); @@ -2321,15 +1667,6 @@ export class WorkplaceSettingsFormModalComponent implements OnInit { this._dialog_ref.close(); } - private _patchModel(patch: Record) { - this.model.update((model) => applyAppSettings(model, patch)); - } - - private async _getMetadata(id: string) { - const metadata = await showMetadata(id, this.settings_key); - return (metadata.details || {}) as Record; - } - public addLegend() { let legend = this.model().explore?.legend || []; if (!(legend instanceof Array)) legend = []; diff --git a/apps/concierge/src/app/visitors/guest-listing.component.ts b/apps/concierge/src/app/visitors/guest-listing.component.ts index e8a05b079e3..809ba414746 100644 --- a/apps/concierge/src/app/visitors/guest-listing.component.ts +++ b/apps/concierge/src/app/visitors/guest-listing.component.ts @@ -29,6 +29,7 @@ import { } from '@placeos/components'; import { UserDetails, UserLabelComponent } from '@placeos/users'; import { ParkingStateService } from '../parking/parking-state.service'; +import { appMetadataKey } from '../ui/app-settings/app-settings.utilities'; import { showVisitorFlag } from './visitor-flags'; import { VisitorsStateService } from './visitors-state.service'; @@ -945,10 +946,10 @@ export class GuestListingComponent extends AsyncHandler { } private async _loadVisitorKioskConfig(bld: any) { - const visitor_kiosk_app = - this._settings.get('app.visitor_kiosk_metadata_key') || - this._settings.get('app.visitor_kiosk_app') || - 'visitor-kiosk_app'; + const visitor_kiosk_app = appMetadataKey( + this._settings, + 'visitor-kiosk', + ); const metadata: any = await showMetadata(bld.id, visitor_kiosk_app); const org_metadata: any = await showMetadata( this._org.organisation.id, diff --git a/apps/concierge/src/app/zone-manager/zone-manager.component.ts b/apps/concierge/src/app/zone-manager/zone-manager.component.ts index 204dce4f7bd..e885adb701f 100644 --- a/apps/concierge/src/app/zone-manager/zone-manager.component.ts +++ b/apps/concierge/src/app/zone-manager/zone-manager.component.ts @@ -1,6 +1,5 @@ import { Component, OnInit, computed, inject, signal } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; -import { MatDialog } from '@angular/material/dialog'; import { MatMenuModule } from '@angular/material/menu'; import { MatTabsModule } from '@angular/material/tabs'; import { MatTooltipModule } from '@angular/material/tooltip'; @@ -13,6 +12,7 @@ import { LevelListComponent } from '../level-manager/level-list.component'; import { LevelManagementService } from '../level-manager/level-management.service'; import { RegionListComponent } from '../region-manager/region-list.component'; import { RegionManagementService } from '../region-manager/region-management.service'; +import { AppSettingsMenuComponent } from '../ui/app-settings/app-settings-menu.component'; import { ApplicationSidebarComponent } from '../ui/app-sidebar.component'; @Component({ @@ -32,7 +32,7 @@ import { ApplicationSidebarComponent } from '../ui/app-sidebar.component';
- - - - - - +

`, @@ -152,13 +107,13 @@ import { ApplicationSidebarComponent } from '../ui/app-sidebar.component'; RegionListComponent, BuildingListComponent, LevelListComponent, + AppSettingsMenuComponent, ], }) export class ZoneManagerComponent extends AsyncHandler implements OnInit { private readonly _region_service = inject(RegionManagementService); private readonly _building_service = inject(BuildingManagementService); private readonly _level_service = inject(LevelManagementService); - private readonly _dialog = inject(MatDialog); private readonly _org = inject(OrganisationService); private readonly _route = inject(ActivatedRoute); private readonly _router = inject(Router); @@ -167,6 +122,11 @@ export class ZoneManagerComponent extends AsyncHandler implements OnInit { private readonly TAB_NAMES = ['regions', 'buildings', 'levels']; + /** Organisation zone for the organisation App Settings menu. */ + public get organisation() { + return this._org.organisation; + } + public readonly addButtonText = computed(() => { const tab_index = this.selected_tab(); if (tab_index === 0) return 'APP.CONCIERGE.REGIONS_ADD'; @@ -212,36 +172,4 @@ export class ZoneManagerComponent extends AsyncHandler implements OnInit { }), ); } - - public async editWorkplaceSettings() { - const { WorkplaceSettingsFormModalComponent } = - await import('../ui/app-settings/workplace-settings-form-modal.component'); - this._dialog.open(WorkplaceSettingsFormModalComponent, { - data: { zone: this._org.organisation }, - }); - } - - public async editConciergeSettings() { - const { ConciergeSettingsFormModalComponent } = - await import('../ui/app-settings/concierge-settings-form-modal.component'); - this._dialog.open(ConciergeSettingsFormModalComponent, { - data: { zone: this._org.organisation }, - }); - } - - public async editBookingPanelSettings() { - const { BookingPanelSettingsModalComponent } = - await import('../ui/app-settings/booking-panel-settings-modal.component'); - this._dialog.open(BookingPanelSettingsModalComponent, { - data: { zone: this._org.organisation }, - }); - } - - public async editVisitorKioskSettings() { - const { VisitorKioskSettingsFormModalComponent } = - await import('../ui/app-settings/visitor-kiosk-settings-form-modal.component'); - this._dialog.open(VisitorKioskSettingsFormModalComponent, { - data: { zone: this._org.organisation }, - }); - } } diff --git a/apps/concierge/src/tests/building-manager/building-list.component.spec.ts b/apps/concierge/src/tests/building-manager/building-list.component.spec.ts index b82c96ecc4e..436da4aa6a5 100644 --- a/apps/concierge/src/tests/building-manager/building-list.component.spec.ts +++ b/apps/concierge/src/tests/building-manager/building-list.component.spec.ts @@ -1,6 +1,5 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { signal } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MockProvider } from 'ng-mocks'; @@ -8,7 +7,6 @@ import { setNotifyOutlet } from '@placeos/common'; import { BuildingListComponent } from '../../app/building-manager/building-list.component'; import { BuildingManagementService } from '../../app/building-manager/building-management.service'; -import { WorkplaceSettingsFormModalComponent } from '../../app/ui/app-settings/workplace-settings-form-modal.component'; describe('BuildingListComponent', () => { let spectator: Spectator; @@ -23,7 +21,6 @@ describe('BuildingListComponent', () => { providers: [ MockProvider(BuildingManagementService, {}), MockProvider(Clipboard, {}), - MockProvider(MatDialog, { open: vi.fn() }), ], }); @@ -46,7 +43,6 @@ describe('BuildingListComponent', () => { providers: [ { provide: BuildingManagementService, useValue: manager }, { provide: Clipboard, useValue: { copy } }, - { provide: MatDialog, useValue: { open: vi.fn() } }, ], }); }); @@ -85,13 +81,4 @@ describe('BuildingListComponent', () => { expect(manager.setSupportIssueTypes).toHaveBeenCalledWith(row); expect(manager.removeBuilding).toHaveBeenCalledWith(row); }); - - it('should open the workplace settings modal for the selected zone', async () => { - const zone = { id: 'b1' }; - await spectator.component.editWorkplaceSettings(zone); - expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith( - WorkplaceSettingsFormModalComponent, - { data: { zone } }, - ); - }); }); diff --git a/apps/concierge/src/tests/level-manager/level-list.component.spec.ts b/apps/concierge/src/tests/level-manager/level-list.component.spec.ts index af6b802780d..adeb2f79011 100644 --- a/apps/concierge/src/tests/level-manager/level-list.component.spec.ts +++ b/apps/concierge/src/tests/level-manager/level-list.component.spec.ts @@ -87,7 +87,7 @@ describe('LevelListComponent', () => { }); it('should open the booking panel settings modal for the level', async () => { - const level = { id: 'lvl-1' }; + const level = { id: 'lvl-1', name: 'Level 1' }; await spectator.component.editBookingPanelSettings(level); expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith( BookingPanelSettingsModalComponent, diff --git a/apps/concierge/src/tests/region-manager/region-list.component.spec.ts b/apps/concierge/src/tests/region-manager/region-list.component.spec.ts index 116eb85e088..e0557a56d30 100644 --- a/apps/concierge/src/tests/region-manager/region-list.component.spec.ts +++ b/apps/concierge/src/tests/region-manager/region-list.component.spec.ts @@ -1,6 +1,5 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { signal } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MockProvider } from 'ng-mocks'; @@ -8,7 +7,6 @@ import { setNotifyOutlet } from '@placeos/common'; import { RegionListComponent } from '../../app/region-manager/region-list.component'; import { RegionManagementService } from '../../app/region-manager/region-management.service'; -import { ConciergeSettingsFormModalComponent } from '../../app/ui/app-settings/concierge-settings-form-modal.component'; describe('RegionListComponent', () => { let spectator: Spectator; @@ -23,7 +21,6 @@ describe('RegionListComponent', () => { providers: [ MockProvider(RegionManagementService, {}), MockProvider(Clipboard, {}), - MockProvider(MatDialog, { open: vi.fn() }), ], }); @@ -43,7 +40,6 @@ describe('RegionListComponent', () => { providers: [ { provide: RegionManagementService, useValue: manager }, { provide: Clipboard, useValue: { copy } }, - { provide: MatDialog, useValue: { open: vi.fn() } }, ], }); }); @@ -75,13 +71,4 @@ describe('RegionListComponent', () => { spectator.component.copyToClipboard('r1'); expect(notify_open).not.toHaveBeenCalled(); }); - - it('should open the concierge settings modal for the selected zone', async () => { - const zone = { id: 'r1' }; - await spectator.component.editConciergeSettings(zone); - expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith( - ConciergeSettingsFormModalComponent, - { data: { zone } }, - ); - }); }); diff --git a/apps/concierge/src/tests/ui/app-settings/app-settings-menu.component.spec.ts b/apps/concierge/src/tests/ui/app-settings/app-settings-menu.component.spec.ts new file mode 100644 index 00000000000..4fceca5a6f9 --- /dev/null +++ b/apps/concierge/src/tests/ui/app-settings/app-settings-menu.component.spec.ts @@ -0,0 +1,59 @@ +import { MatDialog } from '@angular/material/dialog'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { MockProvider } from 'ng-mocks'; + +import { + AppSettingsMenuComponent, + openAppSettings, +} from '../../../app/ui/app-settings/app-settings-menu.component'; +import { BookingPanelSettingsModalComponent } from '../../../app/ui/app-settings/booking-panel-settings-modal.component'; +import { ConciergeSettingsFormModalComponent } from '../../../app/ui/app-settings/concierge-settings-form-modal.component'; +import { VisitorKioskSettingsFormModalComponent } from '../../../app/ui/app-settings/visitor-kiosk-settings-form-modal.component'; +import { WorkplaceSettingsFormModalComponent } from '../../../app/ui/app-settings/workplace-settings-form-modal.component'; + +describe('openAppSettings', () => { + it.each([ + ['workplace', WorkplaceSettingsFormModalComponent], + ['concierge', ConciergeSettingsFormModalComponent], + ['booking-panel', BookingPanelSettingsModalComponent], + ['visitor-kiosk', VisitorKioskSettingsFormModalComponent], + ] as const)( + 'should open the %s modal for the zone', + async (kind, modal) => { + const dialog = { open: vi.fn() } as unknown as MatDialog; + const zone = { id: 'zone-1', name: 'Zone' }; + + await openAppSettings(dialog, kind, zone); + + expect(dialog.open).toHaveBeenCalledWith(modal, { data: { zone } }); + }, + ); +}); + +describe('AppSettingsMenuComponent', () => { + let spectator: Spectator; + const zone = { id: 'zone-1', name: 'Zone' }; + + const createComponent = createComponentFactory({ + component: AppSettingsMenuComponent, + providers: [MockProvider(MatDialog, { open: vi.fn() })], + detectChanges: false, + }); + + beforeEach(() => { + spectator = createComponent(); + spectator.setInput('zone', zone); + spectator.detectChanges(); + }); + + it('should open the chosen modal for its zone', async () => { + expect(spectator.component.menu()).toBeTruthy(); + + await spectator.component.open('concierge'); + + expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith( + ConciergeSettingsFormModalComponent, + { data: { zone } }, + ); + }); +}); diff --git a/apps/concierge/src/tests/ui/app-settings/app-settings.utilities.spec.ts b/apps/concierge/src/tests/ui/app-settings/app-settings.utilities.spec.ts index 1b7a63e9bef..69965c1b03d 100644 --- a/apps/concierge/src/tests/ui/app-settings/app-settings.utilities.spec.ts +++ b/apps/concierge/src/tests/ui/app-settings/app-settings.utilities.spec.ts @@ -1,11 +1,18 @@ +import { setCurrentUser, SettingsService, StaffUser } from '@placeos/common'; +import * as ts_client from '@placeos/ts-client'; import { applyAppSettings, applyBookingFallbacks, + appMetadataKey, appSettingOverrides, + loadLayeredAppSettings, LOCKER_BOOKING_FALLBACKS, mergeAppSettings, + saveAppSettings, } from '../../../app/ui/app-settings/app-settings.utilities'; +vi.mock('@placeos/ts-client', { spy: true }); + describe('application settings utilities', () => { it('should merge settings at two levels and replace nested values', () => { const settings = mergeAppSettings( @@ -190,4 +197,161 @@ describe('application settings utilities', () => { ), ).toEqual({ lockers: { available_period: 30, max_duration: 480 } }); }); + + describe('appMetadataKey', () => { + const settingsWith = (values: Record) => + ({ get: (key: string) => values[key] }) as SettingsService; + + it('should use the first key setting that is set', () => { + const settings = settingsWith({ + 'app.concierge_app': 'legacy_concierge', + 'app.visitor_kiosk_metadata_key': 'kiosk_key', + 'app.visitor_kiosk_app': 'legacy_kiosk', + }); + expect(appMetadataKey(settings, 'concierge')).toBe( + 'legacy_concierge', + ); + expect(appMetadataKey(settings, 'visitor-kiosk')).toBe('kiosk_key'); + }); + + it('should fall back to the default key of each app', () => { + const settings = settingsWith({ 'app.workplace_app': 'ignored' }); + expect(appMetadataKey(settings, 'concierge')).toBe('concierge_app'); + expect(appMetadataKey(settings, 'workplace')).toBe('workplace_app'); + expect(appMetadataKey(settings, 'visitor-kiosk')).toBe( + 'visitor-kiosk_app', + ); + }); + }); + + describe('loadLayeredAppSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ts_client.showMetadata).mockImplementation( + async (id: string) => + ({ details: { default_route: id } }) as never, + ); + }); + + afterEach(() => vi.restoreAllMocks()); + + it('should layer defaults, organisation and parent settings', async () => { + const layers = await loadLayeredAppSettings( + { id: 'zone-1', name: 'Zone', parent_id: 'parent-1' }, + 'org-1', + 'app_key', + { default_route: 'default', use_region: true }, + ); + + expect(ts_client.showMetadata).toHaveBeenCalledWith( + 'org-1', + 'app_key', + ); + expect(layers).toEqual({ + inherited: { default_route: 'parent-1', use_region: true }, + stored: { default_route: 'zone-1' }, + }); + }); + + it('should skip a parent that is the organisation', async () => { + const layers = await loadLayeredAppSettings( + { id: 'zone-1', name: 'Zone', parent_id: 'org-1' }, + 'org-1', + 'app_key', + {}, + ); + + expect(ts_client.showMetadata).toHaveBeenCalledTimes(2); + expect(layers.inherited).toEqual({ default_route: 'org-1' }); + }); + + it('should load only the organisation for the organisation', async () => { + const layers = await loadLayeredAppSettings( + { id: 'org-1', name: 'Org' }, + 'org-1', + 'app_key', + { default_route: 'default' }, + ); + + expect(ts_client.showMetadata).toHaveBeenCalledTimes(1); + expect(layers).toEqual({ + inherited: { default_route: 'default' }, + stored: { default_route: 'org-1' }, + }); + }); + }); + + describe('saveAppSettings', () => { + const save = () => + saveAppSettings( + 'zone-1', + 'app_key', + 'Concierge', + { + inherited: { default_route: '/a', week_start: 1 }, + stored: { use_region: true }, + }, + { default_route: '/a', week_start: 1 }, + { default_route: '/b', week_start: 1 }, + ); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ts_client.updateMetadata).mockResolvedValue({} as never); + }); + + afterEach(() => { + setCurrentUser(null); + vi.restoreAllMocks(); + }); + + it('should save overrides with the editor details', async () => { + setCurrentUser( + new StaffUser({ + id: 'user-1', + name: 'Admin User', + email: 'admin@example.com', + groups: ['placeos_admin'], + }), + ); + + await save(); + + expect(ts_client.updateMetadata).toHaveBeenCalledWith('zone-1', { + name: 'app_key', + description: expect.stringMatching( + /\|C\] Concierge Application Settings$/, + ), + details: { + use_region: true, + default_route: '/b', + edited_by: { + id: 'user-1', + name: 'Admin User', + email: 'admin@example.com', + domain: location.hostname, + role: 'Admin', + }, + }, + }); + }); + + it.each([ + [['placeos_support'], 'Support'], + [[], 'User'], + ])('should save the role of %j users as %s', async (groups, role) => { + setCurrentUser(new StaffUser({ id: 'user-1', groups })); + + await save(); + + expect(ts_client.updateMetadata).toHaveBeenCalledWith( + 'zone-1', + expect.objectContaining({ + details: expect.objectContaining({ + edited_by: expect.objectContaining({ role }), + }), + }), + ); + }); + }); }); diff --git a/apps/concierge/src/tests/ui/app-settings/concierge-settings-form-modal.component.spec.ts b/apps/concierge/src/tests/ui/app-settings/concierge-settings-form-modal.component.spec.ts index 7d6cf5d0f0b..77e7b3e44a4 100644 --- a/apps/concierge/src/tests/ui/app-settings/concierge-settings-form-modal.component.spec.ts +++ b/apps/concierge/src/tests/ui/app-settings/concierge-settings-form-modal.component.spec.ts @@ -201,25 +201,6 @@ describe('ConciergeSettingsFormModalComponent', () => { expect(spectator.component.shown_group()).toBe('parking'); }); - it('should update currency filter', () => { - expect(spectator.component.currency_filter()).toBe(''); - spectator.component.updateCurrencyFilter('USD'); - expect(spectator.component.currency_filter()).toBe('USD'); - }); - - it('should clear currency filter when select closes', () => { - spectator.component.updateCurrencyFilter('EUR'); - expect(spectator.component.currency_filter()).toBe('EUR'); - spectator.component.onCurrencySelectStateChange(false); - expect(spectator.component.currency_filter()).toBe(''); - }); - - it('should not clear currency filter when select opens', () => { - spectator.component.updateCurrencyFilter('EUR'); - spectator.component.onCurrencySelectStateChange(true); - expect(spectator.component.currency_filter()).toBe('EUR'); - }); - it('should load settings on init', async () => { await spectator.component.ngOnInit(); expect(ts_client.showMetadata).toHaveBeenCalled(); @@ -421,22 +402,4 @@ describe('ConciergeSettingsFormModalComponent', () => { expect(spectator.component.date_string).toBeTruthy(); expect(typeof spectator.component.date_string).toBe('string'); }); - - it('should set edited_by on save with admin role', async () => { - await spectator.component.ngOnInit(); - await spectator.component.save(); - expect(ts_client.updateMetadata).toHaveBeenCalledWith( - 'zone-1', - expect.objectContaining({ - details: expect.objectContaining({ - edited_by: expect.objectContaining({ - id: 'user-1', - name: 'Test User', - email: 'test@example.com', - role: 'Admin', - }), - }), - }), - ); - }); }); diff --git a/apps/concierge/src/tests/ui/app-settings/settings-currency-select.component.spec.ts b/apps/concierge/src/tests/ui/app-settings/settings-currency-select.component.spec.ts new file mode 100644 index 00000000000..11e493c91bf --- /dev/null +++ b/apps/concierge/src/tests/ui/app-settings/settings-currency-select.component.spec.ts @@ -0,0 +1,66 @@ +import { Component, signal } from '@angular/core'; +import { form } from '@angular/forms/signals'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; + +import { SettingsCurrencySelectComponent } from '../../../app/ui/app-settings/settings-currency-select.component'; + +/** Form that hosts the select under test. */ +@Component({ + template: ` + + `, + imports: [SettingsCurrencySelectComponent], +}) +class HostComponent { + public readonly model = signal({ currency: 'USD' }); + public readonly form = form(this.model); +} + +describe('SettingsCurrencySelectComponent', () => { + let spectator: Spectator; + let select: SettingsCurrencySelectComponent; + + const createComponent = createComponentFactory({ + component: HostComponent, + imports: [NoopAnimationsModule], + }); + + beforeEach(() => { + spectator = createComponent(); + select = spectator.query(SettingsCurrencySelectComponent)!; + }); + + it('should name the select with its label', () => { + expect(spectator.query('label')).toHaveAttribute( + 'id', + 'test-currency-label', + ); + expect( + spectator + .query('mat-select') + ?.getAttribute('aria-labelledby') + ?.trim(), + ).toBe('test-currency-label'); + }); + + it('should filter currencies by the search text', () => { + const all = select.filtered_options().length; + select.filter.set(' eur '); + const filtered = select.filtered_options(); + expect(filtered.length).toBeGreaterThan(0); + expect(filtered.length).toBeLessThan(all); + expect(filtered.map((option) => option.code)).toContain('EUR'); + }); + + it('should clear the search only when the select closes', () => { + select.filter.set('EUR'); + select.onOpenedChange(true); + expect(select.filter()).toBe('EUR'); + select.onOpenedChange(false); + expect(select.filter()).toBe(''); + }); +}); diff --git a/apps/concierge/src/tests/ui/app-settings/settings-select.component.spec.ts b/apps/concierge/src/tests/ui/app-settings/settings-select.component.spec.ts new file mode 100644 index 00000000000..4e0163f71b3 --- /dev/null +++ b/apps/concierge/src/tests/ui/app-settings/settings-select.component.spec.ts @@ -0,0 +1,78 @@ +import { Component, signal } from '@angular/core'; +import { form } from '@angular/forms/signals'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; + +import { SettingsSelectComponent } from '../../../app/ui/app-settings/settings-select.component'; + +/** Form that hosts the select under test. */ +@Component({ + template: ` + + `, + imports: [SettingsSelectComponent], +}) +class HostComponent { + public readonly model = signal({ period: 1 as number | null }); + public readonly form = form(this.model); + public readonly OPTIONS = [ + { value: 1, label: '1 Day' }, + { value: 7, label: '1 Week' }, + ]; +} + +describe('SettingsSelectComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: HostComponent, + imports: [NoopAnimationsModule], + }); + + const openOptions = () => { + spectator.click('.mat-mdc-select-trigger'); + return Array.from(document.querySelectorAll('mat-option')); + }; + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should name the select with its label', () => { + const label = spectator.query('label'); + expect(label).toHaveAttribute('for', 'test-period'); + expect(label).toHaveAttribute('id', 'test-period-label'); + expect(label).toHaveText('Available Period'); + expect( + spectator + .query('mat-select') + ?.getAttribute('aria-labelledby') + ?.trim(), + ).toBe('test-period-label'); + expect(spectator.query('mat-hint')).toHaveText( + 'Days ahead users can book', + ); + }); + + it('should list the null option first and write the chosen value', () => { + const options = openOptions(); + expect(options.map((option) => option.textContent?.trim())).toEqual([ + 'No limit', + '1 Day', + '1 Week', + ]); + + (options[2] as HTMLElement).click(); + expect(spectator.component.model().period).toBe(7); + + (openOptions()[0] as HTMLElement).click(); + expect(spectator.component.model().period).toBeNull(); + }); +}); diff --git a/apps/concierge/src/tests/ui/app-settings/visitor-kiosk-settings-form-modal.component.spec.ts b/apps/concierge/src/tests/ui/app-settings/visitor-kiosk-settings-form-modal.component.spec.ts index 5def0b9578c..16068cefc52 100644 --- a/apps/concierge/src/tests/ui/app-settings/visitor-kiosk-settings-form-modal.component.spec.ts +++ b/apps/concierge/src/tests/ui/app-settings/visitor-kiosk-settings-form-modal.component.spec.ts @@ -170,21 +170,6 @@ describe('VisitorKioskSettingsFormModalComponent', () => { ); }); - it('should skip parent metadata when parent is same as org', async () => { - (ts_client.showMetadata as any).mockClear(); - // Override the zone to have parent_id === org id - (spectator.component as any).zone = { - ...mock_zone, - parent_id: 'org-1', - }; - (spectator.component as any)._data = { - zone: { ...mock_zone, parent_id: 'org-1' }, - }; - await spectator.component.ngOnInit(); - // Should only call for org and zone (not parent separately) - expect(ts_client.showMetadata).toHaveBeenCalledTimes(2); - }); - it('should compare organisation settings against application defaults', async () => { (ts_client.showMetadata as any).mockClear(); (ts_client.showMetadata as any).mockResolvedValueOnce({ @@ -315,21 +300,4 @@ describe('VisitorKioskSettingsFormModalComponent', () => { const visitors = spectator.component.model().visitors; expect(visitors.max_duration).toBe(180); }); - - it('should set edited_by on save', async () => { - await spectator.component.ngOnInit(); - await spectator.component.save(); - expect(ts_client.updateMetadata).toHaveBeenCalledWith( - 'zone-1', - expect.objectContaining({ - details: expect.objectContaining({ - edited_by: expect.objectContaining({ - id: 'user-1', - name: 'Test User', - email: 'test@example.com', - }), - }), - }), - ); - }); }); diff --git a/apps/concierge/src/tests/ui/app-settings/workplace-settings-form-modal.component.spec.ts b/apps/concierge/src/tests/ui/app-settings/workplace-settings-form-modal.component.spec.ts index 01a037d2f9b..199d890c372 100644 --- a/apps/concierge/src/tests/ui/app-settings/workplace-settings-form-modal.component.spec.ts +++ b/apps/concierge/src/tests/ui/app-settings/workplace-settings-form-modal.component.spec.ts @@ -200,18 +200,6 @@ describe('WorkplaceSettingsFormModalComponent', () => { expect(spectator.component.shown_group()).toBe('desks'); }); - it('should update currency filter', () => { - expect(spectator.component.currency_filter()).toBe(''); - spectator.component.updateCurrencyFilter('USD'); - expect(spectator.component.currency_filter()).toBe('USD'); - }); - - it('should clear currency filter when select closes', () => { - spectator.component.updateCurrencyFilter('EUR'); - spectator.component.onCurrencySelectStateChange(false); - expect(spectator.component.currency_filter()).toBe(''); - }); - it('should load settings on init', async () => { await spectator.component.ngOnInit(); expect(ts_client.showMetadata).toHaveBeenCalled(); @@ -371,65 +359,4 @@ describe('WorkplaceSettingsFormModalComponent', () => { expect(spectator.component.date_string).toBeTruthy(); expect(typeof spectator.component.date_string).toBe('string'); }); - - it('should set edited_by with admin role', async () => { - await spectator.component.ngOnInit(); - await spectator.component.save(); - expect(ts_client.updateMetadata).toHaveBeenCalledWith( - 'zone-1', - expect.objectContaining({ - details: expect.objectContaining({ - edited_by: expect.objectContaining({ - role: 'Admin', - }), - }), - }), - ); - }); - - it('should set edited_by with Support role for support users', async () => { - setCurrentUser( - new StaffUser({ - id: 'user-2', - name: 'Support User', - email: 'support@example.com', - groups: ['placeos_support'], - }), - ); - await spectator.component.ngOnInit(); - await spectator.component.save(); - expect(ts_client.updateMetadata).toHaveBeenCalledWith( - 'zone-1', - expect.objectContaining({ - details: expect.objectContaining({ - edited_by: expect.objectContaining({ - role: 'Support', - }), - }), - }), - ); - }); - - it('should set edited_by with User role for regular users', async () => { - setCurrentUser( - new StaffUser({ - id: 'user-3', - name: 'Regular User', - email: 'user@example.com', - groups: [], - }), - ); - await spectator.component.ngOnInit(); - await spectator.component.save(); - expect(ts_client.updateMetadata).toHaveBeenCalledWith( - 'zone-1', - expect.objectContaining({ - details: expect.objectContaining({ - edited_by: expect.objectContaining({ - role: 'User', - }), - }), - }), - ); - }); }); diff --git a/apps/concierge/src/tests/zone-manager/zone-manager.component.spec.ts b/apps/concierge/src/tests/zone-manager/zone-manager.component.spec.ts index 0861ff015e7..6c95353b2a1 100644 --- a/apps/concierge/src/tests/zone-manager/zone-manager.component.spec.ts +++ b/apps/concierge/src/tests/zone-manager/zone-manager.component.spec.ts @@ -1,6 +1,5 @@ import { ComponentFixtureAutoDetect } from '@angular/core/testing'; import { convertToParamMap, ActivatedRoute, Router } from '@angular/router'; -import { MatDialog } from '@angular/material/dialog'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { OrganisationService } from '@placeos/common'; import { MockProvider } from 'ng-mocks'; @@ -10,7 +9,6 @@ import { ZoneManagerComponent } from '../../app/zone-manager/zone-manager.compon import { BuildingManagementService } from '../../app/building-manager/building-management.service'; import { LevelManagementService } from '../../app/level-manager/level-management.service'; import { RegionManagementService } from '../../app/region-manager/region-management.service'; -import { WorkplaceSettingsFormModalComponent } from '../../app/ui/app-settings/workplace-settings-form-modal.component'; describe('ZoneManagerComponent', () => { let spectator: Spectator; @@ -29,7 +27,6 @@ describe('ZoneManagerComponent', () => { MockProvider(RegionManagementService, {}), MockProvider(BuildingManagementService, {}), MockProvider(LevelManagementService, {}), - MockProvider(MatDialog, { open: vi.fn() }), MockProvider(OrganisationService, {}), MockProvider(ActivatedRoute, {}), MockProvider(Router, {}), @@ -47,7 +44,6 @@ describe('ZoneManagerComponent', () => { { provide: RegionManagementService, useValue: region_service }, { provide: BuildingManagementService, useValue: building_service }, { provide: LevelManagementService, useValue: level_service }, - { provide: MatDialog, useValue: { open: vi.fn() } }, { provide: OrganisationService, useValue: { @@ -111,12 +107,4 @@ describe('ZoneManagerComponent', () => { await spectator.component.ngOnInit(); expect(spectator.component.selected_tab()).toBe(1); }); - - it('should open the organisation workplace settings modal', async () => { - await spectator.component.editWorkplaceSettings(); - expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith( - WorkplaceSettingsFormModalComponent, - { data: { zone: { id: 'org-1' } } }, - ); - }); }); From 44b5f9625f440725e12735be7a304ae0b90880c0 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 17:07:53 +1100 Subject: [PATCH 08/21] refactor(concierge): share the report page shell and usage parts Eight report pages use one page shell and one query helper. One charts component and one usage list serve the desk and space reports. One query call replaces five identical booking query branches. --- .../reports/assets/assets-report.component.ts | 102 +-- .../site-attendance-report.component.ts | 688 ++++++++---------- .../catering/catering-report.component.ts | 97 +-- .../desks/report-desks-charts.component.ts | 239 ------ .../reports/desks/report-desks.component.ts | 163 ++--- .../lockers/lockers-report.component.ts | 94 +-- .../parking/parking-report.component.ts | 100 +-- .../src/app/reports/report-page.component.ts | 75 ++ ...nt.ts => report-usage-charts.component.ts} | 17 +- ...nent.ts => report-usage-list.component.ts} | 95 ++- .../src/app/reports/reports-state.service.ts | 133 ++-- .../src/app/reports/reports.utilities.ts | 26 + .../report-spaces-overall-list.component.ts | 166 ----- .../reports/spaces/report-spaces.component.ts | 108 +-- .../visitors/visitors-report.component.ts | 90 +-- .../assets/assets-report.component.spec.ts | 16 +- .../site-attendance-report.component.spec.ts | 14 +- .../catering-report.component.spec.ts | 6 +- .../report-desks-charts.component.spec.ts | 65 -- ...eport-desks-overall-list.component.spec.ts | 62 -- .../desks/report-desks.component.spec.ts | 24 +- .../lockers/lockers-report.component.spec.ts | 16 +- .../parking/parking-report.component.spec.ts | 16 +- .../reports/report-page.component.spec.ts | 50 ++ ... => report-usage-charts.component.spec.ts} | 35 +- ...ts => report-usage-list.component.spec.ts} | 49 +- .../tests/reports/reports.utilities.spec.ts | 29 + .../spaces/report-spaces.component.spec.ts | 24 +- .../visitors-report.component.spec.ts | 16 +- 29 files changed, 932 insertions(+), 1683 deletions(-) delete mode 100644 apps/concierge/src/app/reports/desks/report-desks-charts.component.ts create mode 100644 apps/concierge/src/app/reports/report-page.component.ts rename apps/concierge/src/app/reports/{spaces/report-spaces-charts.component.ts => report-usage-charts.component.ts} (93%) rename apps/concierge/src/app/reports/{desks/report-desks-overall-list.component.ts => report-usage-list.component.ts} (70%) delete mode 100644 apps/concierge/src/app/reports/spaces/report-spaces-overall-list.component.ts delete mode 100644 apps/concierge/src/tests/reports/desks/report-desks-charts.component.spec.ts delete mode 100644 apps/concierge/src/tests/reports/desks/report-desks-overall-list.component.spec.ts create mode 100644 apps/concierge/src/tests/reports/report-page.component.spec.ts rename apps/concierge/src/tests/reports/{spaces/report-spaces-charts.component.spec.ts => report-usage-charts.component.spec.ts} (80%) rename apps/concierge/src/tests/reports/{spaces/report-spaces-overall-list.component.spec.ts => report-usage-list.component.spec.ts} (61%) diff --git a/apps/concierge/src/app/reports/assets/assets-report.component.ts b/apps/concierge/src/app/reports/assets/assets-report.component.ts index f1233eb3f36..77e8e1f82d1 100644 --- a/apps/concierge/src/app/reports/assets/assets-report.component.ts +++ b/apps/concierge/src/app/reports/assets/assets-report.component.ts @@ -1,17 +1,14 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { AsyncHandler, themedLogo } from '@placeos/common'; -import { - AuthenticatedImageDirective, - TranslatePipe, -} from '@placeos/components'; +import { AsyncHandler } from '@placeos/common'; import { ReportMetricGuideComponent, ReportMetricGuideItem, } from '../report-metric-guide.component'; +import { ReportPageComponent } from '../report-page.component'; import { ReportsOptionsComponent } from '../reports-options.component'; +import { reportQueryOptions } from '../reports.utilities'; import { AssetReportDailyUsageComponent } from './asset-report-daily-usage.component'; import { AssetReportExpiredItemsComponent } from './asset-report-expired-items.component'; import { AssetReportOverallComponent } from './asset-report-overall.component'; @@ -61,55 +58,29 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ (download)="downloadReport()" (generate)="generateReport()" /> -
-
-
- -
-

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

-
-
- @if (!loading()) { - @if (total_count()) { - - - - - - - } @else { -
-

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

-
- } - } @else { -
- -

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

-
- } -
+ + + + + + + + + `, styles: [ ` @@ -121,9 +92,7 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ `, ], imports: [ - TranslatePipe, - MatProgressSpinnerModule, - AuthenticatedImageDirective, + ReportPageComponent, ReportsOptionsComponent, ReportMetricGuideComponent, AssetReportDailyUsageComponent, @@ -149,25 +118,12 @@ export class AssetsReportComponent extends AsyncHandler implements OnInit { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = themedLogo(); - public ngOnInit() { this.subscription( 'route.query', - this._route.queryParamMap.subscribe((params) => { - if (params.has('start')) { - this._state.setOptions({ start: +params.get('start') }); - } - if (params.has('end')) { - this._state.setOptions({ end: +params.get('end') }); - } - if (params.has('zones') || params.has('zone_ids')) { - const zones = ( - params.get('zones') || params.get('zone_ids') - ).split(','); - if (zones.length) this._state.setOptions({ zones }); - } else this._state.setOptions({ zones: [] }); - }), + this._route.queryParamMap.subscribe((params) => + this._state.setOptions(reportQueryOptions(params)), + ), ); } } diff --git a/apps/concierge/src/app/reports/attendance/site-attendance-report.component.ts b/apps/concierge/src/app/reports/attendance/site-attendance-report.component.ts index 471c92e91a4..77c2709e1ec 100644 --- a/apps/concierge/src/app/reports/attendance/site-attendance-report.component.ts +++ b/apps/concierge/src/app/reports/attendance/site-attendance-report.component.ts @@ -1,9 +1,7 @@ import { Component, computed, inject, signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { AsyncHandler, themedLogo } from '@placeos/common'; +import { AsyncHandler } from '@placeos/common'; import { - AuthenticatedImageDirective, IconComponent, SimpleTableComponent, TranslatePipe, @@ -12,7 +10,9 @@ import { ReportMetricGuideComponent, ReportMetricGuideItem, } from '../report-metric-guide.component'; +import { ReportPageComponent } from '../report-page.component'; import { ReportsOptionsComponent } from '../reports-options.component'; +import { reportQueryOptions } from '../reports.utilities'; import { SiteAttendanceReportChartComponent } from './site-attendance-report-chart.component'; import { SiteAttendanceReportService } from './site-attendance-report.service'; @@ -126,394 +126,348 @@ const PEOPLE_TABLE_METRIC_GUIDE: ReportMetricGuideItem[] = [ (download)="downloadReport()" (generate)="generateReport()" /> -
-
-
- -
-

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

-
-
- @if (!loading()) { - @if (has_data()) { - + + +
-
-

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

-

- {{ report().total_attendance }} -

-
-
-

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

-

- {{ report().total_bookings }} -

-
-
-

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

-

- {{ report().active_types }} -

-
-
-

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

-

- {{ report().unique_people }} -

-
-
-

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

-

- {{ report().business_days }} -

-
+

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

+

+ {{ report().total_attendance }} +

- -
- +
+

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

+

+ {{ report().total_bookings }} +

-
- @for (card of report().cards; track card.id) { +

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

+

+ {{ report().active_types }} +

+
+
+

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

+

+ {{ report().unique_people }} +

+
+
+

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

+

+ {{ report().business_days }} +

+
+
+ +
+ +
+ +
+ @for (card of report().cards; track card.id) { +
+
+
+ {{ details[card.id].icon }} +
+
+

+ {{ details[card.id].label | translate }} +

+

+ {{ card.attendance }} +

+

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

+
+
+
-
-
- {{ details[card.id].icon }} +
+
+ {{ + 'APP.CONCIERGE.REPORTS_TOTAL_BOOKINGS' + | translate + }}
-
-

- {{ - details[card.id].label - | translate - }} -

-

- {{ card.attendance }} -

-

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

+
+ {{ card.bookings }}
- -
-
-
- {{ - 'APP.CONCIERGE.REPORTS_TOTAL_BOOKINGS' - | translate - }} -
-
- {{ card.bookings }} -
+
+
+ {{ + 'APP.CONCIERGE.REPORTS_DAILY_AVERAGE' + | translate + }} +
+
+ {{ card.daily_average }} +
+
+
+
+ {{ + 'APP.CONCIERGE.REPORTS_AVERAGE_LENGTH' + | translate + }}
-
-
- {{ - 'APP.CONCIERGE.REPORTS_DAILY_AVERAGE' - | translate - }} -
-
- {{ card.daily_average }} -
+
+ {{ card.average_length }}
-
-
- {{ - 'APP.CONCIERGE.REPORTS_AVERAGE_LENGTH' - | translate - }} -
-
- {{ card.average_length }} -
+
+
+
+ {{ + 'APP.CONCIERGE.REPORTS_UNIQUE_PEOPLE' + | translate + }}
-
-
- {{ - 'APP.CONCIERGE.REPORTS_UNIQUE_PEOPLE' - | translate - }} -
-
- {{ card.unique_people }} -
+
+ {{ card.unique_people }}
+
-
-
-
- {{ - 'APP.CONCIERGE.REPORTS_RESOURCES_USED' - | translate - }} -
-
- {{ card.resource_summary }} -
+
+
+
+ {{ + 'APP.CONCIERGE.REPORTS_RESOURCES_USED' + | translate + }} +
+
+ {{ card.resource_summary }}
-
-
- {{ card.status_label | translate }} -
-
- {{ card.status_count }} -
-
- {{ card.status_rate }}% -
+
+
+
+ {{ card.status_label | translate }} +
+
+ {{ card.status_count }} +
+
+ {{ card.status_rate }}%
- } -
+
+ } +
-
+
+
-
-

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

- -
- + {{ + 'APP.CONCIERGE.REPORTS_HOSTS_HEADER' | translate - " - > + }} + +
+
+
-
+
+
-
-

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

- -
- +

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

+
+
+
- -
-
- {{ row.name }} -
- @if (row.name !== row.id) { -
- {{ row.id }} -
- } + +
+
+ {{ row.name }}
- - } @else { -
-

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

+ @if (row.name !== row.id) { +
+ {{ row.id }} +
+ }
- } - } @else { -
- -

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

-
- } -
+
+ + `, styles: [ ` @@ -572,11 +526,10 @@ const PEOPLE_TABLE_METRIC_GUIDE: ReportMetricGuideItem[] = [ `, ], imports: [ + ReportPageComponent, ReportsOptionsComponent, - AuthenticatedImageDirective, IconComponent, SimpleTableComponent, - MatProgressSpinnerModule, TranslatePipe, ReportMetricGuideComponent, SiteAttendanceReportChartComponent, @@ -608,28 +561,13 @@ export class SiteAttendanceReportComponent extends AsyncHandler { public readonly hasResource = (id: keyof typeof CARD_DETAILS) => this.report().cards.some((card) => card.id === id); - public readonly logo = themedLogo(); - constructor() { super(); this.subscription( 'route.query', - this._route.queryParamMap.subscribe((params) => { - if (params.has('start')) { - this._state.setOptions({ start: +params.get('start') }); - } - if (params.has('end')) { - this._state.setOptions({ end: +params.get('end') }); - } - if (params.has('zones') || params.has('zone_ids')) { - const zones = ( - params.get('zones') || params.get('zone_ids') - ).split(','); - if (zones.length) this._state.setOptions({ zones }); - } else { - this._state.setOptions({ zones: [] }); - } - }), + this._route.queryParamMap.subscribe((params) => + this._state.setOptions(reportQueryOptions(params)), + ), ); } } diff --git a/apps/concierge/src/app/reports/catering/catering-report.component.ts b/apps/concierge/src/app/reports/catering/catering-report.component.ts index 4a875059b0b..39bd89951d8 100644 --- a/apps/concierge/src/app/reports/catering/catering-report.component.ts +++ b/apps/concierge/src/app/reports/catering/catering-report.component.ts @@ -1,22 +1,18 @@ import { Component, computed, effect, inject, signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; import { AsyncHandler, OrganisationService, SettingsService, - themedLogo, } from '@placeos/common'; -import { - AuthenticatedImageDirective, - TranslatePipe, -} from '@placeos/components'; import { ReportMetricGuideComponent, ReportMetricGuideItem, } from '../report-metric-guide.component'; +import { ReportPageComponent } from '../report-page.component'; import { ReportsOptionsComponent } from '../reports-options.component'; import { ReportsStateService } from '../reports-state.service'; +import { reportQueryOptions } from '../reports.utilities'; import { CateringReportItemsComponent } from './catering-report-items.component'; import { CateringReportOrdersComponent } from './catering-report-orders.component'; import { CateringReportOverallComponent } from './catering-report-overall.component'; @@ -60,53 +56,25 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ (download)="downloadReport()" (generate)="generateReport()" /> -
-
-
- -
-

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

-
-
- @if (!loading()) { - @if (total_count()) { - - - - - } @else { -
-

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

-
- } - } @else { -
- -

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

-
- } -
+ + + + + + + `, styles: [ ` @@ -118,14 +86,12 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ `, ], imports: [ - TranslatePipe, - MatProgressSpinnerModule, + ReportPageComponent, ReportsOptionsComponent, ReportMetricGuideComponent, CateringReportItemsComponent, CateringReportOrdersComponent, CateringReportOverallComponent, - AuthenticatedImageDirective, ], }) export class CateringReportComponent extends AsyncHandler { @@ -146,8 +112,6 @@ export class CateringReportComponent extends AsyncHandler { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = themedLogo(); - public get using_bookings() { return this._settings.get('app.catering.use_bookings') == true; } @@ -163,22 +127,9 @@ export class CateringReportComponent extends AsyncHandler { this.subscription( 'route.query', - this._route.queryParamMap.subscribe((params) => { - if (params.has('start')) { - this._state.setOptions({ start: +params.get('start') }); - } - if (params.has('end')) { - this._state.setOptions({ end: +params.get('end') }); - } - if (params.has('zones') || params.has('zone_ids')) { - const id_list = - params.get('zones') || params.get('zone_ids'); - const zones = id_list.split(','); - if (zones.length) this._state.setOptions({ zones }); - } else { - this._state.setOptions({ zones: [] }); - } - }), + this._route.queryParamMap.subscribe((params) => + this._state.setOptions(reportQueryOptions(params)), + ), ); } } diff --git a/apps/concierge/src/app/reports/desks/report-desks-charts.component.ts b/apps/concierge/src/app/reports/desks/report-desks-charts.component.ts deleted file mode 100644 index f472411ac78..00000000000 --- a/apps/concierge/src/app/reports/desks/report-desks-charts.component.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { - Component, - computed, - effect, - ElementRef, - inject, - input, - OnDestroy, - viewChild, -} from '@angular/core'; -import { ReportsStateService } from '../reports-state.service'; - -import { - AsyncHandler, - OrganisationService, - SettingsService, -} from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { - ArcElement, - CategoryScale, - Chart, - Legend, - LinearScale, - LineController, - LineElement, - PieController, - PointElement, - Tooltip, -} from 'chart.js'; -import { format } from 'date-fns'; - -Chart.register( - LineController, - LineElement, - PointElement, - LinearScale, - CategoryScale, - PieController, - ArcElement, - Tooltip, - Legend, -); - -@Component({ - selector: 'report-desks-charts', - template: ` -
-
-
- {{ 'APP.CONCIERGE.REPORTS_DAILY_HEADER' | translate }} -
-
- -
-
-
-
- {{ 'APP.CONCIERGE.REPORTS_LEVEL_UTIL_HEADER' | translate }} -
-
- -
-
-
- `, - styles: [ - ` - :host { - display: block; - } - - .is-print canvas { - width: 8cm !important; - } - `, - ], - imports: [TranslatePipe], -}) -export class ReportDesksChartsComponent - extends AsyncHandler - implements OnDestroy -{ - private _state = inject(ReportsStateService); - private _org = inject(OrganisationService); - private _settings = inject(SettingsService); - - public readonly print = input(false); - public readonly day_list = this._state.day_list; - public readonly stats = computed( - () => - [this._state.options(), this._state.counts()] as [ - any, - Record, - ], - ); - - private _daily_chart_el = - viewChild>('dailyChart'); - private _level_chart_el = - viewChild>('levelChart'); - private _day_chart: Chart | null = null; - private _level_chart: Chart | null = null; - - constructor() { - super(); - effect(() => { - this.day_list(); - this.stats(); - this.print(); - this._daily_chart_el(); - this._level_chart_el(); - this.updateCharts(); - }); - } - - public override ngOnDestroy() { - super.ngOnDestroy(); - this._day_chart?.destroy(); - this._level_chart?.destroy(); - } - - public updateCharts() { - this.timeout( - 'update_charts', - () => { - const day_list = this.day_list(); - this.updateDailyChart(day_list); - const [mappings, counts] = this.stats(); - this.updateLevelChart(mappings, counts); - }, - 50, - ); - } - - public updateDailyChart(list) { - const el = this._daily_chart_el()?.nativeElement; - if (!el) return; - this._day_chart?.destroy(); - this._day_chart = new Chart(el, { - type: 'line', - data: { - labels: list.map((_) => format(_.date, 'dd MMM')), - datasets: [ - { - data: list.map((_) => +_.utilisation), - borderColor: 'rgb(59, 130, 246)', - backgroundColor: 'rgba(59, 130, 246, 0.1)', - fill: true, - tension: 0.3, - }, - ], - }, - options: { - responsive: true, - maintainAspectRatio: false, - animation: this.print() ? false : undefined, - plugins: { - legend: { display: false }, - }, - scales: { - y: { - beginAtZero: true, - }, - }, - }, - }); - } - - public updateLevelChart(mapping, count) { - const el = this._level_chart_el()?.nativeElement; - if (!el) return; - let { zones } = mapping || { zones: [] }; - if (!zones.length) { - zones = this._settings.get('app.use_region') - ? this._org.levelsForRegion().map((_) => _.id) - : this._org.levelsForBuilding().map((_) => _.id); - } - const zone_list = (zones || []).filter((_) => (count[_] || 0) > 0); - const labels = zone_list.map((_) => { - const level = this._org.levelWithID([_]); - return level?.display_name || level.name || ''; - }); - const data = zone_list.map((_) => count[_] || 0); - this._level_chart?.destroy(); - this._level_chart = new Chart(el, { - type: 'pie', - data: { - labels, - datasets: [ - { - data, - backgroundColor: [ - 'rgb(59, 130, 246)', - 'rgb(16, 185, 129)', - 'rgb(245, 158, 11)', - 'rgb(239, 68, 68)', - 'rgb(139, 92, 246)', - 'rgb(236, 72, 153)', - 'rgb(20, 184, 166)', - 'rgb(249, 115, 22)', - ], - }, - ], - }, - options: { - responsive: true, - maintainAspectRatio: false, - animation: this.print() ? false : undefined, - plugins: { - legend: { - position: 'right', - }, - }, - }, - }); - } -} diff --git a/apps/concierge/src/app/reports/desks/report-desks.component.ts b/apps/concierge/src/app/reports/desks/report-desks.component.ts index 95faaa982d5..106f4620cb2 100644 --- a/apps/concierge/src/app/reports/desks/report-desks.component.ts +++ b/apps/concierge/src/app/reports/desks/report-desks.component.ts @@ -1,20 +1,18 @@ import { Component, computed, inject, signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { AsyncHandler, themedLogo } from '@placeos/common'; -import { - AuthenticatedImageDirective, - TranslatePipe, -} from '@placeos/components'; +import { AsyncHandler } from '@placeos/common'; +import { TranslatePipe } from '@placeos/components'; import { ReportMetricGuideComponent, ReportMetricGuideItem, } from '../report-metric-guide.component'; +import { ReportPageComponent } from '../report-page.component'; +import { ReportUsageChartsComponent } from '../report-usage-charts.component'; +import { ReportUsageListComponent } from '../report-usage-list.component'; import { ReportsOptionsComponent } from '../reports-options.component'; import { ReportsStateService } from '../reports-state.service'; -import { ReportDesksChartsComponent } from './report-desks-charts.component'; +import { reportQueryOptions } from '../reports.utilities'; import { ReportDesksLevelListComponent } from './report-desks-level-list.component'; -import { ReportDesksOverallListComponent } from './report-desks-overall-list.component'; const METRIC_GUIDE: ReportMetricGuideItem[] = [ { @@ -54,85 +52,56 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ (download)="downloadReport()" (generate)="generateReport()" /> -
-
-
- -
-

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

-
-
- @if (!loading()) { - @if (total_count()) { - -
-
-

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

-

{{ total_count() || 0 }}

-
-
-

Active

-

{{ active_count() || 0 }}

-
-
-

Rejected

-

{{ cancelled_count() || 0 }}

-
-
-

Cancelled

-

{{ deleted_count() || 0 }}

-
-
-

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

-

{{ utilisation() || 0 }}%

-
+ + +
+
+

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

+

{{ total_count() || 0 }}

+
+
+

Active

+

{{ active_count() || 0 }}

- - - - } @else { -
-

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

+
+

Rejected

+

{{ cancelled_count() || 0 }}

+
+
+

Cancelled

+

{{ deleted_count() || 0 }}

+
+
+

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

+

{{ utilisation() || 0 }}%

- } - } @else { -
- -

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

- } -
+ + + + + `, styles: [ ` @@ -148,14 +117,13 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ `, ], imports: [ + ReportPageComponent, TranslatePipe, - MatProgressSpinnerModule, ReportsOptionsComponent, ReportMetricGuideComponent, - ReportDesksChartsComponent, + ReportUsageChartsComponent, ReportDesksLevelListComponent, - ReportDesksOverallListComponent, - AuthenticatedImageDirective, + ReportUsageListComponent, ], }) export class ReportDesksComponent extends AsyncHandler { @@ -183,29 +151,14 @@ export class ReportDesksComponent extends AsyncHandler { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = themedLogo(); - constructor() { super(); this._state.setOptions({ type: 'desks' }); this.subscription( 'route.query', - this._route.queryParamMap.subscribe((params) => { - if (params.has('start')) { - this._state.setOptions({ start: +params.get('start') }); - } - if (params.has('end')) { - this._state.setOptions({ end: +params.get('end') }); - } - if (params.has('zones') || params.has('zone_ids')) { - const id_list = - params.get('zones') || params.get('zone_ids'); - const zones = id_list.split(',').filter((_) => _); - if (zones.length) this._state.setOptions({ zones }); - } else { - this._state.setOptions({ zones: [] }); - } - }), + this._route.queryParamMap.subscribe((params) => + this._state.setOptions(reportQueryOptions(params, true)), + ), ); } } diff --git a/apps/concierge/src/app/reports/lockers/lockers-report.component.ts b/apps/concierge/src/app/reports/lockers/lockers-report.component.ts index 434e2e378c7..59f173297ef 100644 --- a/apps/concierge/src/app/reports/lockers/lockers-report.component.ts +++ b/apps/concierge/src/app/reports/lockers/lockers-report.component.ts @@ -1,16 +1,13 @@ import { Component, computed, inject, signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { AsyncHandler, themedLogo } from '@placeos/common'; -import { - AuthenticatedImageDirective, - TranslatePipe, -} from '@placeos/components'; +import { AsyncHandler } from '@placeos/common'; import { ReportMetricGuideComponent, ReportMetricGuideItem, } from '../report-metric-guide.component'; +import { ReportPageComponent } from '../report-page.component'; import { ReportsOptionsComponent } from '../reports-options.component'; +import { reportQueryOptions } from '../reports.utilities'; import { LockersReportChartsComponent } from './lockers-report-charts.component'; import { LockersReportDailyUsageComponent } from './lockers-report-daily-usage.component'; import { LockersReportListComponent } from './lockers-report-list.component'; @@ -55,50 +52,24 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ (download)="downloadReport()" (generate)="generateReport()" /> -
-
-
- -
-

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

-
-
- @if (!loading()) { - @if (total_count()) { - - - - - - } @else { -
-

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

-
- } - } @else { -
- -

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

-
- } -
+ + + + + + + + `, styles: [ ` @@ -110,14 +81,12 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ `, ], imports: [ - MatProgressSpinnerModule, - TranslatePipe, + ReportPageComponent, ReportMetricGuideComponent, LockersReportChartsComponent, LockersReportOverallComponent, LockersReportDailyUsageComponent, LockersReportListComponent, - AuthenticatedImageDirective, ReportsOptionsComponent, ], }) @@ -133,28 +102,13 @@ export class LockersReportComponent extends AsyncHandler { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = themedLogo(); - constructor() { super(); this.subscription( 'route.query', - this._route.queryParamMap.subscribe((params) => { - if (params.has('start')) { - this._state.setOptions({ start: +params.get('start') }); - } - if (params.has('end')) { - this._state.setOptions({ end: +params.get('end') }); - } - if (params.has('zones') || params.has('zone_ids')) { - const zones = ( - params.get('zones') || params.get('zone_ids') - ).split(','); - if (zones.length) this._state.setOptions({ zones }); - } else { - this._state.setOptions({ zones: [] }); - } - }), + this._route.queryParamMap.subscribe((params) => + this._state.setOptions(reportQueryOptions(params)), + ), ); } } diff --git a/apps/concierge/src/app/reports/parking/parking-report.component.ts b/apps/concierge/src/app/reports/parking/parking-report.component.ts index b931c797c84..7911af50fb8 100644 --- a/apps/concierge/src/app/reports/parking/parking-report.component.ts +++ b/apps/concierge/src/app/reports/parking/parking-report.component.ts @@ -1,17 +1,14 @@ import { Component, computed, inject, signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { AsyncHandler, themedLogo } from '@placeos/common'; -import { - AuthenticatedImageDirective, - TranslatePipe, -} from '@placeos/components'; +import { AsyncHandler } from '@placeos/common'; import { ReportMetricGuideComponent, ReportMetricGuideItem, } from '../report-metric-guide.component'; +import { ReportPageComponent } from '../report-page.component'; import { ReportsOptionsComponent } from '../reports-options.component'; +import { reportQueryOptions } from '../reports.utilities'; import { ParkingReportChartsComponent } from './parking-report-charts.component'; import { ParkingReportDailyUsageComponent } from './parking-report-daily-usage.component'; import { ParkingReportListComponent } from './parking-report-list.component'; @@ -62,53 +59,27 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ (download)="downloadReport()" (generate)="generateReport()" /> -
-
-
- -
-

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

-
-
- @if (!loading()) { - @if (total_count()) { - - - - - - - } @else { -
-

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

-
- } - } @else { -
- -

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

-
- } -
+ + + + + + + + + `, styles: [ ` @@ -120,9 +91,7 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ `, ], imports: [ - MatProgressSpinnerModule, - TranslatePipe, - AuthenticatedImageDirective, + ReportPageComponent, ReportMetricGuideComponent, ParkingReportOverallComponent, ParkingReportDailyUsageComponent, @@ -144,28 +113,13 @@ export class ParkingReportComponent extends AsyncHandler { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = themedLogo(); - constructor() { super(); this.subscription( 'route.query', - this._route.queryParamMap.subscribe((params) => { - if (params.has('start')) { - this._state.setOptions({ start: +params.get('start') }); - } - if (params.has('end')) { - this._state.setOptions({ end: +params.get('end') }); - } - if (params.has('zones') || params.has('zone_ids')) { - const zones = ( - params.get('zones') || params.get('zone_ids') - ).split(','); - if (zones.length) this._state.setOptions({ zones }); - } else { - this._state.setOptions({ zones: [] }); - } - }), + this._route.queryParamMap.subscribe((params) => + this._state.setOptions(reportQueryOptions(params)), + ), ); } } diff --git a/apps/concierge/src/app/reports/report-page.component.ts b/apps/concierge/src/app/reports/report-page.component.ts new file mode 100644 index 00000000000..c3c644f80fe --- /dev/null +++ b/apps/concierge/src/app/reports/report-page.component.ts @@ -0,0 +1,75 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { Component, contentChild, input, TemplateRef } from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { themedLogo } from '@placeos/common'; +import { + AuthenticatedImageDirective, + TranslatePipe, +} from '@placeos/components'; + +/** + * Scroll area of a report page: the logo header bar, then a loading + * spinner, an empty message or the report body. + * + * Put the report body in a child ``. It is created only when + * the report is not loading and has data, so its components do not run + * while the page is loading or empty. + * + * ```html + * + * ... + * + * ``` + */ +@Component({ + selector: 'report-page', + host: { + class: 'relative block h-1/2 w-full flex-1 overflow-auto print:h-auto print:overflow-visible', + }, + template: ` +
+
+ +
+

+ {{ header() | translate }} +

+
+
+ @if (!loading()) { + @if (has_data()) { + + } @else { +
+

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

+
+ } + } @else { +
+ +

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

+
+ } + `, + imports: [ + NgTemplateOutlet, + MatProgressSpinnerModule, + AuthenticatedImageDirective, + TranslatePipe, + ], +}) +export class ReportPageComponent { + /** Translation key of the report title */ + public readonly header = input.required(); + public readonly loading = input(false); + public readonly has_data = input(false); + + public readonly content = contentChild.required(TemplateRef); + public readonly logo = themedLogo(); +} diff --git a/apps/concierge/src/app/reports/spaces/report-spaces-charts.component.ts b/apps/concierge/src/app/reports/report-usage-charts.component.ts similarity index 93% rename from apps/concierge/src/app/reports/spaces/report-spaces-charts.component.ts rename to apps/concierge/src/app/reports/report-usage-charts.component.ts index 090ab8a3541..d5ee60983ca 100644 --- a/apps/concierge/src/app/reports/spaces/report-spaces-charts.component.ts +++ b/apps/concierge/src/app/reports/report-usage-charts.component.ts @@ -7,10 +7,9 @@ import { OnDestroy, viewChild, } from '@angular/core'; -import { ReportsStateService } from '../reports-state.service'; - import { AsyncHandler, + HashMap, OrganisationService, SettingsService, } from '@placeos/common'; @@ -28,6 +27,7 @@ import { Tooltip, } from 'chart.js'; import { format } from 'date-fns'; +import { ReportOptions, ReportsStateService } from './reports-state.service'; Chart.register( LineController, @@ -41,8 +41,13 @@ Chart.register( Legend, ); +/** + * Daily utilisation line chart and level utilisation pie chart for the + * desks and spaces reports. Reads the day list, options and counts from + * `ReportsStateService`. + */ @Component({ - selector: 'report-spaces-charts', + selector: 'report-usage-charts', template: `
) { const el = this._daily_chart_el()?.nativeElement; if (!el) return; this._day_chart?.destroy(); @@ -181,7 +186,7 @@ export class ReportSpacesChartsComponent }); } - public updateLevelChart(mapping, count) { + public updateLevelChart(mapping: ReportOptions, count: HashMap) { const el = this._level_chart_el()?.nativeElement; if (!el) return; let { zones } = mapping || { zones: [] }; diff --git a/apps/concierge/src/app/reports/desks/report-desks-overall-list.component.ts b/apps/concierge/src/app/reports/report-usage-list.component.ts similarity index 70% rename from apps/concierge/src/app/reports/desks/report-desks-overall-list.component.ts rename to apps/concierge/src/app/reports/report-usage-list.component.ts index 5e638c7c6ca..b56e26deeb6 100644 --- a/apps/concierge/src/app/reports/desks/report-desks-overall-list.component.ts +++ b/apps/concierge/src/app/reports/report-usage-list.component.ts @@ -1,48 +1,70 @@ -import { Component, inject, input } from '@angular/core'; -import { downloadFile, jsonToCsv } from '@placeos/common'; -import { format } from 'date-fns'; - import { CommonModule } from '@angular/common'; +import { Component, computed, inject, input } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { downloadFile, jsonToCsv } from '@placeos/common'; import { IconComponent, SimpleTableComponent, TranslatePipe, } from '@placeos/components'; +import { format } from 'date-fns'; import { ReportMetricGuideComponent, ReportMetricGuideItem, -} from '../report-metric-guide.component'; -import { ReportsStateService } from '../reports-state.service'; -import { formatReportPercentage } from '../reports.utilities'; +} from './report-metric-guide.component'; +import { ReportsStateService } from './reports-state.service'; +import { formatReportPercentage } from './reports.utilities'; -const TABLE_METRIC_GUIDE: ReportMetricGuideItem[] = [ - { - label: 'Approved', - description: 'Bookings on the day where the booking is approved.', - }, - { - label: 'Total requests', - description: +/** Column guide for the daily usage table. Only two descriptions differ. */ +function tableMetricGuide( + total_requests: string, + utilisation: string, +): ReportMetricGuideItem[] { + return [ + { + label: 'Approved', + description: 'Bookings on the day where the booking is approved.', + }, + { label: 'Total requests', description: total_requests }, + { + label: 'Rejected / Cancelled', + description: + 'Displayed as count and percentage of total requests for that day.', + }, + { label: 'Utilisation', description: utilisation }, + ]; +} + +/** Report text and export settings for each usage table type */ +const USAGE_LISTS = { + desks: { + metric_guide: tableMetricGuide( 'All desk bookings on the day, including active, rejected, and cancelled bookings.', - }, - { - label: 'Rejected / Cancelled', - description: - 'Displayed as count and percentage of total requests for that day.', - }, - { - label: 'Utilisation', - description: 'Active desk bookings divided by the greater of active bookings or available desk count for that day.', + ), + date_format: 'MMM d, y(EEE)', + file_name: 'desks-usage.csv', }, -]; + spaces: { + metric_guide: tableMetricGuide( + 'All room bookings on the day, including active, rejected, and cancelled events.', + 'Booked room time divided by available room time for that day.', + ), + date_format: 'MMMM d, y(EEE)', + file_name: 'spaces-usage.csv', + }, +}; +/** + * Daily usage table for the desks and spaces reports, with a CSV download. + * Reads the day list from `ReportsStateService`. `type` selects the + * column guide text and the export date format and file name. + */ @Component({ - selector: 'report-desks-overall-list', + selector: 'report-usage-list', template: ` -
+
@@ -70,7 +92,7 @@ const TABLE_METRIC_GUIDE: ReportMetricGuideItem[] = [ }
@@ -110,13 +132,12 @@ const TABLE_METRIC_GUIDE: ReportMetricGuideItem[] = [ content: percent_template, }, ]" - [page_size]="print() ? 0 : 10" [sortable]="true" + [page_size]="print() ? 0 : 10" [empty_message]=" 'APP.CONCIERGE.REPORTS_DAILY_EMPTY' | translate " - > - + >
{{ data | date: 'mediumDate' }}
@@ -136,31 +157,33 @@ const TABLE_METRIC_GUIDE: ReportMetricGuideItem[] = [
`, imports: [ - SimpleTableComponent, CommonModule, TranslatePipe, IconComponent, + SimpleTableComponent, MatRippleModule, MatTooltipModule, ReportMetricGuideComponent, ], }) -export class ReportDesksOverallListComponent { +export class ReportUsageListComponent { private _state = inject(ReportsStateService); + public readonly type = input.required(); public readonly print = input(false); - public readonly table_metric_guide = TABLE_METRIC_GUIDE; + public readonly config = computed(() => USAGE_LISTS[this.type()]); public readonly day_list = this._state.day_list; public readonly download = async () => { + const { date_format, file_name } = this.config(); const data = this.day_list().map((d) => ({ ...d, - date: format(d.date, 'MMM d, y(EEE)'), + date: format(d.date, date_format), cancelled: formatReportPercentage(d.cancelled, d.count), deleted: formatReportPercentage(d.deleted, d.count), })); - downloadFile('desks-usage.csv', jsonToCsv(data)); + downloadFile(file_name, jsonToCsv(data)); }; public readonly formatPercent = formatReportPercentage; diff --git a/apps/concierge/src/app/reports/reports-state.service.ts b/apps/concierge/src/app/reports/reports-state.service.ts index c20bb123d67..71f211cab1a 100644 --- a/apps/concierge/src/app/reports/reports-state.service.ts +++ b/apps/concierge/src/app/reports/reports-state.service.ts @@ -19,6 +19,7 @@ import { AsyncHandler, BookableHoursRange, Booking, + BookingType, CalendarEvent, downloadFile, flatten, @@ -64,6 +65,18 @@ export interface ReportOptions { zones?: string[]; } +/** Booking API type for each report type that loads bookings */ +const BOOKING_TYPES: Record< + Exclude, + BookingType +> = { + desks: 'desk', + parking: 'parking', + lockers: 'locker', + assets: 'asset-request', + catering: 'catering-order', +}; + const DAYS_OF_WEEK_INDEX = { 0: 'sunday', 1: 'monday', @@ -326,22 +339,15 @@ export class ReportsStateService extends AsyncHandler { resource_type: ReportOptions['type'], use_assets: boolean, ): Promise<[string, number]> { - switch (resource_type) { - case 'desks': { - if (use_assets) { - const desks = await queryDeskAssets(zone_id).catch( - () => [], - ); - return [zone_id, desks.length]; - } - const metadata = await showMetadata(zone_id, 'desks').catch( - () => ({ details: [] }), - ); - return [zone_id, metadata.details.length]; - } - default: - return [zone_id, 0]; + if (resource_type !== 'desks') return [zone_id, 0]; + if (use_assets) { + const desks = await queryDeskAssets(zone_id).catch(() => []); + return [zone_id, desks.length]; } + const metadata = await showMetadata(zone_id, 'desks').catch(() => ({ + details: [], + })); + return [zone_id, metadata.details.length]; } /** Aggregated statistics for the active bookings and resource counts */ @@ -506,11 +512,6 @@ export class ReportsStateService extends AsyncHandler { period_start: getUnixTime(start), period_end: getUnixTime(end), }; - const bookings_query = { - ...query, - include_deleted: true, - include_checked_out: true, - }; this._load_controller?.abort(); const controller = new AbortController(); this._load_controller = controller; @@ -518,75 +519,29 @@ export class ReportsStateService extends AsyncHandler { let list: (CalendarEvent | Booking)[] = []; let failed = false; try { - switch (options.type) { - case 'desks': - list = await queryAllBookings( - { - ...bookings_query, - zones, - type: 'desk', - limit: 200, - }, - { signal }, - ); - break; - case 'parking': - list = await queryAllBookings( - { - ...bookings_query, - zones, - type: 'parking', - limit: 200, - }, - { signal }, - ); - break; - case 'lockers': - list = await queryAllBookings( - { - ...bookings_query, - zones, - type: 'locker', - limit: 200, - }, - { signal }, - ); - break; - case 'assets': - list = await queryAllBookings( - { - ...bookings_query, - zones, - type: 'asset-request', - limit: 200, - }, - { signal }, - ); - break; - case 'catering': - list = await queryAllBookings( - { - ...bookings_query, - zones, - type: 'catering-order', - limit: 200, - }, - { signal }, - ); - break; - case 'events': - list = await queryAllEvents( - { - ...query, - zone_ids: zones, - include_cancelled: true, - limit: 200, - }, - { signal }, - ); - break; - default: - list = []; + const booking_type = BOOKING_TYPES[options.type]; + if (options.type === 'events') { + list = await queryAllEvents( + { + ...query, + zone_ids: zones, + include_cancelled: true, + limit: 200, + }, + { signal }, + ); + } else if (booking_type) { + list = await queryAllBookings( + { + ...query, + include_deleted: true, + include_checked_out: true, + zones, + type: booking_type, + limit: 200, + }, + { signal }, + ); } } catch (_) { list = []; diff --git a/apps/concierge/src/app/reports/reports.utilities.ts b/apps/concierge/src/app/reports/reports.utilities.ts index 75be5f41ffe..db8afd7c051 100644 --- a/apps/concierge/src/app/reports/reports.utilities.ts +++ b/apps/concierge/src/app/reports/reports.utilities.ts @@ -1,3 +1,4 @@ +import { ParamMap } from '@angular/router'; import { BookableHoursRange, Booking, @@ -5,6 +6,31 @@ import { HashMap, } from '@placeos/common'; +/** + * Report options from a report page URL query. Reads `start`, `end` and the + * comma list in `zones` or `zone_ids`. Without a zone list, zones are + * cleared. With `drop_empty_zones`, empty ids are removed, and a list with no + * ids leaves the current zones as they are. + */ +export function reportQueryOptions( + params: ParamMap, + drop_empty_zones = false, +): { start?: number; end?: number; zones?: string[] } { + const options: { start?: number; end?: number; zones?: string[] } = {}; + if (params.has('start')) options.start = +params.get('start'); + if (params.has('end')) options.end = +params.get('end'); + if (params.has('zones') || params.has('zone_ids')) { + const id_list = params.get('zones') || params.get('zone_ids') || ''; + const zones = id_list + .split(',') + .filter((id) => !drop_empty_zones || !!id); + if (zones.length) options.zones = zones; + } else { + options.zones = []; + } + return options; +} + function isActiveReportBooking(booking: Booking): boolean { return !booking.deleted && !isDeclinedReportBooking(booking); } diff --git a/apps/concierge/src/app/reports/spaces/report-spaces-overall-list.component.ts b/apps/concierge/src/app/reports/spaces/report-spaces-overall-list.component.ts deleted file mode 100644 index 42da43d3d50..00000000000 --- a/apps/concierge/src/app/reports/spaces/report-spaces-overall-list.component.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Component, inject, input } from '@angular/core'; -import { downloadFile, jsonToCsv } from '@placeos/common'; -import { format } from 'date-fns'; - -import { CommonModule } from '@angular/common'; -import { MatRippleModule } from '@angular/material/core'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { - IconComponent, - SimpleTableComponent, - TranslatePipe, -} from '@placeos/components'; -import { - ReportMetricGuideComponent, - ReportMetricGuideItem, -} from '../report-metric-guide.component'; -import { ReportsStateService } from '../reports-state.service'; -import { formatReportPercentage } from '../reports.utilities'; - -const TABLE_METRIC_GUIDE: ReportMetricGuideItem[] = [ - { - label: 'Approved', - description: 'Bookings on the day where the booking is approved.', - }, - { - label: 'Total requests', - description: - 'All room bookings on the day, including active, rejected, and cancelled events.', - }, - { - label: 'Rejected / Cancelled', - description: - 'Displayed as count and percentage of total requests for that day.', - }, - { - label: 'Utilisation', - description: - 'Booked room time divided by available room time for that day.', - }, -]; - -@Component({ - selector: 'report-spaces-overall-list', - template: ` -
-
-
-

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

- @if (!print()) { - - } - -
- - -
{{ data | date: 'mediumDate' }}
-
- -
{{ data || '0' }}%
-
- -
- {{ formatPercent(data, row.count) }} -
-
-
-
- `, - imports: [ - CommonModule, - TranslatePipe, - IconComponent, - SimpleTableComponent, - MatRippleModule, - MatTooltipModule, - ReportMetricGuideComponent, - ], -}) -export class ReportSpacesOverallListComponent { - private _state = inject(ReportsStateService); - - public readonly print = input(false); - public readonly table_metric_guide = TABLE_METRIC_GUIDE; - - public readonly day_list = this._state.day_list; - - public readonly download = async () => { - const data = this.day_list().map((d) => ({ - ...d, - date: format(d.date, 'MMMM d, y(EEE)'), - cancelled: formatReportPercentage(d.cancelled, d.count), - deleted: formatReportPercentage(d.deleted, d.count), - })); - downloadFile('spaces-usage.csv', jsonToCsv(data)); - }; - - public readonly formatPercent = formatReportPercentage; -} diff --git a/apps/concierge/src/app/reports/spaces/report-spaces.component.ts b/apps/concierge/src/app/reports/spaces/report-spaces.component.ts index 5b7f37c00e6..786f3752e2c 100644 --- a/apps/concierge/src/app/reports/spaces/report-spaces.component.ts +++ b/apps/concierge/src/app/reports/spaces/report-spaces.component.ts @@ -1,19 +1,16 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { AsyncHandler, themedLogo } from '@placeos/common'; -import { - AuthenticatedImageDirective, - TranslatePipe, -} from '@placeos/components'; +import { AsyncHandler } from '@placeos/common'; import { ReportMetricGuideComponent, ReportMetricGuideItem, } from '../report-metric-guide.component'; +import { ReportPageComponent } from '../report-page.component'; +import { ReportUsageChartsComponent } from '../report-usage-charts.component'; +import { ReportUsageListComponent } from '../report-usage-list.component'; import { ReportsOptionsComponent } from '../reports-options.component'; import { ReportsStateService } from '../reports-state.service'; -import { ReportSpacesChartsComponent } from './report-spaces-charts.component'; -import { ReportSpacesOverallListComponent } from './report-spaces-overall-list.component'; +import { reportQueryOptions } from '../reports.utilities'; import { ReportSpacesOverallComponent } from './report-spaces-overall.component'; import { ReportSpacesSpaceListingComponent } from './report-spaces-space-listing.component'; import { ReportSpacesUserListingComponent } from './report-spaces-user-listing.component'; @@ -56,55 +53,27 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ (download)="downloadReport()" (generate)="generateReport()" /> -
-
-
- -
-

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

-
-
- @if (!loading()) { - @if (total_count()) { - - - - - - - } @else { -
-

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

-
- } - } @else { -
- -

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

-
- } -
+ + + + + + + + + `, styles: [ ` @@ -116,14 +85,12 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ `, ], imports: [ - MatProgressSpinnerModule, - TranslatePipe, + ReportPageComponent, ReportsOptionsComponent, ReportMetricGuideComponent, - AuthenticatedImageDirective, - ReportSpacesChartsComponent, + ReportUsageChartsComponent, ReportSpacesOverallComponent, - ReportSpacesOverallListComponent, + ReportUsageListComponent, ReportSpacesSpaceListingComponent, ReportSpacesUserListingComponent, ], @@ -144,26 +111,13 @@ export class ReportSpacesComponent extends AsyncHandler implements OnInit { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = themedLogo(); - public ngOnInit() { this._state.setOptions({ type: 'events' }); this.subscription( 'route.query', - this._route.queryParamMap.subscribe((params) => { - if (params.has('start')) { - this._state.setOptions({ start: +params.get('start') }); - } - if (params.has('end')) { - this._state.setOptions({ end: +params.get('end') }); - } - if (params.has('zones') || params.has('zone_ids')) { - const id_list = - params.get('zones') || params.get('zone_ids'); - const zones = id_list.split(','); - if (zones.length) this._state.setOptions({ zones }); - } else this._state.setOptions({ zones: [] }); - }), + this._route.queryParamMap.subscribe((params) => + this._state.setOptions(reportQueryOptions(params)), + ), ); } } diff --git a/apps/concierge/src/app/reports/visitors/visitors-report.component.ts b/apps/concierge/src/app/reports/visitors/visitors-report.component.ts index 38d130783c2..ccc71f3a937 100644 --- a/apps/concierge/src/app/reports/visitors/visitors-report.component.ts +++ b/apps/concierge/src/app/reports/visitors/visitors-report.component.ts @@ -1,16 +1,13 @@ import { Component, OnInit, computed, inject, signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { AsyncHandler, themedLogo } from '@placeos/common'; -import { - AuthenticatedImageDirective, - TranslatePipe, -} from '@placeos/components'; +import { AsyncHandler } from '@placeos/common'; import { ReportMetricGuideComponent, ReportMetricGuideItem, } from '../report-metric-guide.component'; +import { ReportPageComponent } from '../report-page.component'; import { ReportsOptionsComponent } from '../reports-options.component'; +import { reportQueryOptions } from '../reports.utilities'; import { VisitorReportDailyUsageComponent } from './visitor-report-daily-usage.component'; import { VisitorReportListComponent } from './visitor-report-list.component'; import { VisitorReportOverallComponent } from './visitor-report-overall.component'; @@ -53,49 +50,23 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ (download)="downloadReport()" (generate)="generateReport()" /> -
-
-
- -
-

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

-
-
- @if (!loading()) { - @if (total_count()) { - - - - - } @else { -
-

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

-
- } - } @else { -
- -

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

-
- } -
+ + + + + + + `, styles: [ ` @@ -107,14 +78,12 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ `, ], imports: [ + ReportPageComponent, ReportsOptionsComponent, ReportMetricGuideComponent, - AuthenticatedImageDirective, - MatProgressSpinnerModule, VisitorReportOverallComponent, VisitorReportDailyUsageComponent, VisitorReportListComponent, - TranslatePipe, ], }) export class VisitorsReportComponent extends AsyncHandler implements OnInit { @@ -132,25 +101,12 @@ export class VisitorsReportComponent extends AsyncHandler implements OnInit { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = themedLogo(); - public ngOnInit() { this.subscription( 'route.query', - this._route.queryParamMap.subscribe((params) => { - if (params.has('start')) { - this._state.setOptions({ start: +params.get('start') }); - } - if (params.has('end')) { - this._state.setOptions({ end: +params.get('end') }); - } - if (params.has('zones') || params.has('zone_ids')) { - const zones = ( - params.get('zones') || params.get('zone_ids') - ).split(','); - if (zones.length) this._state.setOptions({ zones }); - } else this._state.setOptions({ zones: [] }); - }), + this._route.queryParamMap.subscribe((params) => + this._state.setOptions(reportQueryOptions(params)), + ), ); } } diff --git a/apps/concierge/src/tests/reports/assets/assets-report.component.spec.ts b/apps/concierge/src/tests/reports/assets/assets-report.component.spec.ts index ccec83ab5de..669811b2ba3 100644 --- a/apps/concierge/src/tests/reports/assets/assets-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/assets/assets-report.component.spec.ts @@ -1,8 +1,6 @@ import { signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { AuthenticatedImageDirective } from '@placeos/components'; import { BehaviorSubject } from 'rxjs'; import { AssetReportDailyUsageComponent } from 'apps/concierge/src/app/reports/assets/asset-report-daily-usage.component'; @@ -14,7 +12,7 @@ import { AssetsReportComponent } from 'apps/concierge/src/app/reports/assets/ass import { AssetsReportService } from 'apps/concierge/src/app/reports/assets/assets-report.service'; import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; -import { MockComponent, MockDirective } from 'ng-mocks'; +import { MockComponent } from 'ng-mocks'; describe('AssetsReportComponent', () => { let spectator: Spectator; @@ -35,9 +33,7 @@ describe('AssetsReportComponent', () => { MockComponent(AssetReportProductUsageComponent), MockComponent(AssetReportUsersComponent), MockComponent(AssetReportExpiredItemsComponent), - MockDirective(AuthenticatedImageDirective), ], - imports: [MatProgressSpinnerModule], }); beforeEach(() => { @@ -74,16 +70,20 @@ describe('AssetsReportComponent', () => { }); it('should apply query params to report options', () => { - expect(set_options).toHaveBeenCalledWith({ start: 100 }); - expect(set_options).toHaveBeenCalledWith({ end: 200 }); expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, zones: ['level-1', 'level-2'], }); }); it('should clear zones when query params omit them', () => { query_params.next(convertToParamMap({ start: '100', end: '200' })); - expect(set_options).toHaveBeenCalledWith({ zones: [] }); + expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, + zones: [], + }); }); it('should expose total count and has_data from stats', () => { diff --git a/apps/concierge/src/tests/reports/attendance/site-attendance-report.component.spec.ts b/apps/concierge/src/tests/reports/attendance/site-attendance-report.component.spec.ts index db0f4ebdb74..936e870e486 100644 --- a/apps/concierge/src/tests/reports/attendance/site-attendance-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/attendance/site-attendance-report.component.spec.ts @@ -108,13 +108,11 @@ describe('SiteAttendanceReportComponent', () => { it('should apply query params to report options', () => { expect( spectator.inject(SiteAttendanceReportService).setOptions, - ).toHaveBeenCalledWith({ start: 100 }); - expect( - spectator.inject(SiteAttendanceReportService).setOptions, - ).toHaveBeenCalledWith({ end: 200 }); - expect( - spectator.inject(SiteAttendanceReportService).setOptions, - ).toHaveBeenCalledWith({ zones: ['level-1', 'level-2'] }); + ).toHaveBeenCalledWith({ + start: 100, + end: 200, + zones: ['level-1', 'level-2'], + }); }); it('should expose report state and has_data from the service', () => { @@ -160,6 +158,6 @@ describe('SiteAttendanceReportComponent', () => { expect( spectator.inject(SiteAttendanceReportService).setOptions, - ).toHaveBeenCalledWith({ zones: [] }); + ).toHaveBeenCalledWith({ start: 100, end: 200, zones: [] }); }); }); diff --git a/apps/concierge/src/tests/reports/catering/catering-report.component.spec.ts b/apps/concierge/src/tests/reports/catering/catering-report.component.spec.ts index 4acc6762f32..4f4eff78a8b 100644 --- a/apps/concierge/src/tests/reports/catering/catering-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/catering/catering-report.component.spec.ts @@ -2,13 +2,11 @@ import { createRoutingFactory, SpectatorRouting, } from '@ngneat/spectator/vitest'; -import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; +import { MockComponent, MockProvider } from 'ng-mocks'; import { signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { Router } from '@angular/router'; import { SettingsService } from '@placeos/common'; -import { AuthenticatedImageDirective } from '@placeos/components'; import { CateringReportItemsComponent } from 'apps/concierge/src/app/reports/catering/catering-report-items.component'; import { CateringReportOrdersComponent } from 'apps/concierge/src/app/reports/catering/catering-report-orders.component'; import { CateringReportOverallComponent } from 'apps/concierge/src/app/reports/catering/catering-report-overall.component'; @@ -25,7 +23,6 @@ describe('CateringReportComponent', () => { MockComponent(CateringReportItemsComponent), MockComponent(CateringReportOrdersComponent), MockComponent(ReportsOptionsComponent), - MockDirective(AuthenticatedImageDirective), ], providers: [ MockProvider(ReportsStateService, { @@ -36,7 +33,6 @@ describe('CateringReportComponent', () => { MockProvider(Router, {}), MockProvider(SettingsService, { get: vi.fn() }), ], - imports: [MatProgressSpinnerModule], }); beforeEach(() => (spectator = createComponent())); diff --git a/apps/concierge/src/tests/reports/desks/report-desks-charts.component.spec.ts b/apps/concierge/src/tests/reports/desks/report-desks-charts.component.spec.ts deleted file mode 100644 index 12aeafb4212..00000000000 --- a/apps/concierge/src/tests/reports/desks/report-desks-charts.component.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { signal } from '@angular/core'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { OrganisationService, SettingsService } from '@placeos/common'; -import { MockProvider } from 'ng-mocks'; - -import { ReportDesksChartsComponent } from 'apps/concierge/src/app/reports/desks/report-desks-charts.component'; -import { ReportsStateService } from 'apps/concierge/src/app/reports/reports-state.service'; - -describe('ReportDesksChartsComponent', () => { - let spectator: Spectator; - let options: ReturnType>; - let counts: ReturnType>; - let day_list: ReturnType>; - - const createComponent = createComponentFactory({ - component: ReportDesksChartsComponent, - providers: [ - MockProvider(ReportsStateService, {}), - MockProvider(SettingsService, { - get: vi.fn(() => false), - } as any), - MockProvider(OrganisationService, { - levelsForBuilding: vi.fn(() => []), - levelsForRegion: vi.fn(() => []), - levelWithID: vi.fn(() => ({ display_name: 'Level 1' })), - } as any), - ], - }); - - beforeEach(() => { - vi.useFakeTimers(); - options = signal({ zones: ['level-1'] }); - counts = signal({ 'level-1': 4 }); - day_list = signal([ - { - date: new Date('2026-04-06T00:00:00').valueOf(), - utilisation: 30, - }, - ]); - spectator = createComponent({ - providers: [ - { - provide: ReportsStateService, - useValue: { options, counts, day_list }, - }, - ], - }); - }); - - afterEach(() => { - vi.clearAllTimers(); - vi.useRealTimers(); - }); - - it('should expose the day list from the state service', () => { - expect(spectator.component.day_list()).toHaveLength(1); - }); - - it('should pair the active options with the resource counts', () => { - expect(spectator.component.stats()).toEqual([ - { zones: ['level-1'] }, - { 'level-1': 4 }, - ]); - }); -}); diff --git a/apps/concierge/src/tests/reports/desks/report-desks-overall-list.component.spec.ts b/apps/concierge/src/tests/reports/desks/report-desks-overall-list.component.spec.ts deleted file mode 100644 index c0f4c9b02a2..00000000000 --- a/apps/concierge/src/tests/reports/desks/report-desks-overall-list.component.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { signal } from '@angular/core'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { IconComponent, SimpleTableComponent } from '@placeos/components'; -import { MockComponent, MockProvider } from 'ng-mocks'; - -import { ReportDesksOverallListComponent } from 'apps/concierge/src/app/reports/desks/report-desks-overall-list.component'; -import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; -import { ReportsStateService } from 'apps/concierge/src/app/reports/reports-state.service'; - -import { captureDownloads } from '../download-capture.helper'; - -describe('ReportDesksOverallListComponent', () => { - let spectator: Spectator; - let downloads: ReturnType; - let day_list: ReturnType>; - - const createComponent = createComponentFactory({ - component: ReportDesksOverallListComponent, - declarations: [ - MockComponent(SimpleTableComponent), - MockComponent(IconComponent), - MockComponent(ReportMetricGuideComponent), - ], - imports: [MatTooltipModule], - providers: [MockProvider(ReportsStateService, {})], - }); - - afterEach(() => downloads.restore()); - - beforeEach(() => { - downloads = captureDownloads(); - day_list = signal([ - { - date: new Date('2026-04-06T00:00:00').valueOf(), - approved: 3, - count: 5, - cancelled: 1, - deleted: 1, - utilisation: '40.0', - }, - ]); - spectator = createComponent({ - providers: [ - { provide: ReportsStateService, useValue: { day_list } }, - ], - }); - }); - - it('should expose the daily list from the state service', () => { - expect(spectator.component.day_list()).toHaveLength(1); - }); - - it('should format booking percentages against total requests', () => { - expect(spectator.component.formatPercent(1, 5)).toBe('1 (20%)'); - }); - - it('should download the daily usage table on request', async () => { - await spectator.component.download(); - expect(downloads.filename).toBe('desks-usage.csv'); - }); -}); diff --git a/apps/concierge/src/tests/reports/desks/report-desks.component.spec.ts b/apps/concierge/src/tests/reports/desks/report-desks.component.spec.ts index d263f6a60ed..848cdc92d96 100644 --- a/apps/concierge/src/tests/reports/desks/report-desks.component.spec.ts +++ b/apps/concierge/src/tests/reports/desks/report-desks.component.spec.ts @@ -1,18 +1,16 @@ import { signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { AuthenticatedImageDirective } from '@placeos/components'; import { BehaviorSubject } from 'rxjs'; -import { ReportDesksChartsComponent } from 'apps/concierge/src/app/reports/desks/report-desks-charts.component'; import { ReportDesksLevelListComponent } from 'apps/concierge/src/app/reports/desks/report-desks-level-list.component'; -import { ReportDesksOverallListComponent } from 'apps/concierge/src/app/reports/desks/report-desks-overall-list.component'; import { ReportDesksComponent } from 'apps/concierge/src/app/reports/desks/report-desks.component'; import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; +import { ReportUsageChartsComponent } from 'apps/concierge/src/app/reports/report-usage-charts.component'; +import { ReportUsageListComponent } from 'apps/concierge/src/app/reports/report-usage-list.component'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; import { ReportsStateService } from 'apps/concierge/src/app/reports/reports-state.service'; -import { MockComponent, MockDirective } from 'ng-mocks'; +import { MockComponent } from 'ng-mocks'; describe('ReportDesksComponent', () => { let spectator: Spectator; @@ -28,12 +26,10 @@ describe('ReportDesksComponent', () => { declarations: [ MockComponent(ReportsOptionsComponent), MockComponent(ReportMetricGuideComponent), - MockComponent(ReportDesksChartsComponent), + MockComponent(ReportUsageChartsComponent), MockComponent(ReportDesksLevelListComponent), - MockComponent(ReportDesksOverallListComponent), - MockDirective(AuthenticatedImageDirective), + MockComponent(ReportUsageListComponent), ], - imports: [MatProgressSpinnerModule], }); beforeEach(() => { @@ -77,16 +73,20 @@ describe('ReportDesksComponent', () => { it('should set the report type to desks and apply query params', () => { expect(set_options).toHaveBeenCalledWith({ type: 'desks' }); - expect(set_options).toHaveBeenCalledWith({ start: 100 }); - expect(set_options).toHaveBeenCalledWith({ end: 200 }); expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, zones: ['level-1', 'level-2'], }); }); it('should clear zones when query params omit them', () => { query_params.next(convertToParamMap({ start: '100', end: '200' })); - expect(set_options).toHaveBeenCalledWith({ zones: [] }); + expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, + zones: [], + }); }); it('should derive booking counts and utilisation from stats', () => { diff --git a/apps/concierge/src/tests/reports/lockers/lockers-report.component.spec.ts b/apps/concierge/src/tests/reports/lockers/lockers-report.component.spec.ts index 5977bfa8334..98456d46312 100644 --- a/apps/concierge/src/tests/reports/lockers/lockers-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/lockers/lockers-report.component.spec.ts @@ -1,8 +1,6 @@ import { signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { AuthenticatedImageDirective } from '@placeos/components'; import { BehaviorSubject } from 'rxjs'; import { LockersReportChartsComponent } from 'apps/concierge/src/app/reports/lockers/lockers-report-charts.component'; @@ -13,7 +11,7 @@ import { LockersReportComponent } from 'apps/concierge/src/app/reports/lockers/l import { LockersReportService } from 'apps/concierge/src/app/reports/lockers/lockers-report.service'; import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; -import { MockComponent, MockDirective } from 'ng-mocks'; +import { MockComponent } from 'ng-mocks'; describe('LockersReportComponent', () => { let spectator: Spectator; @@ -33,9 +31,7 @@ describe('LockersReportComponent', () => { MockComponent(LockersReportOverallComponent), MockComponent(LockersReportDailyUsageComponent), MockComponent(LockersReportListComponent), - MockDirective(AuthenticatedImageDirective), ], - imports: [MatProgressSpinnerModule], }); beforeEach(() => { @@ -72,16 +68,20 @@ describe('LockersReportComponent', () => { }); it('should apply query params to report options', () => { - expect(set_options).toHaveBeenCalledWith({ start: 100 }); - expect(set_options).toHaveBeenCalledWith({ end: 200 }); expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, zones: ['level-1', 'level-2'], }); }); it('should clear zones when query params omit them', () => { query_params.next(convertToParamMap({ start: '100', end: '200' })); - expect(set_options).toHaveBeenCalledWith({ zones: [] }); + expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, + zones: [], + }); }); it('should expose the booking count from the service', () => { diff --git a/apps/concierge/src/tests/reports/parking/parking-report.component.spec.ts b/apps/concierge/src/tests/reports/parking/parking-report.component.spec.ts index d9607c6703f..fbb60b8dccc 100644 --- a/apps/concierge/src/tests/reports/parking/parking-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/parking/parking-report.component.spec.ts @@ -1,11 +1,9 @@ import { signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; +import { MockComponent, MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; -import { AuthenticatedImageDirective } from '@placeos/components'; import { ParkingReportChartsComponent } from 'apps/concierge/src/app/reports/parking/parking-report-charts.component'; import { ParkingReportDailyUsageComponent } from 'apps/concierge/src/app/reports/parking/parking-report-daily-usage.component'; import { ParkingReportListComponent } from 'apps/concierge/src/app/reports/parking/parking-report-list.component'; @@ -32,9 +30,7 @@ describe('ParkingReportComponent', () => { MockComponent(ParkingReportChartsComponent), MockComponent(ParkingReportListComponent), MockComponent(ParkingReportDailyUsageComponent), - MockDirective(AuthenticatedImageDirective), ], - imports: [MatProgressSpinnerModule], providers: [ MockProvider(ParkingReportService, { bookings: signal([]), @@ -82,16 +78,20 @@ describe('ParkingReportComponent', () => { }); it('should apply query params to the report options', () => { - expect(set_options).toHaveBeenCalledWith({ start: 100 }); - expect(set_options).toHaveBeenCalledWith({ end: 200 }); expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, zones: ['lvl-1', 'lvl-2'], }); }); it('should clear zones when the query params omit them', () => { query_params.next(convertToParamMap({ start: '100', end: '200' })); - expect(set_options).toHaveBeenCalledWith({ zones: [] }); + expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, + zones: [], + }); }); it('should expose the booking count as the total', () => { diff --git a/apps/concierge/src/tests/reports/report-page.component.spec.ts b/apps/concierge/src/tests/reports/report-page.component.spec.ts new file mode 100644 index 00000000000..aaf37c51a09 --- /dev/null +++ b/apps/concierge/src/tests/reports/report-page.component.spec.ts @@ -0,0 +1,50 @@ +import { Component, signal } from '@angular/core'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; + +import { ReportPageComponent } from 'apps/concierge/src/app/reports/report-page.component'; + +@Component({ + selector: 'test-report-body', + template: ` + +

Body

+
+ `, + imports: [ReportPageComponent], +}) +class TestReportBodyComponent { + public readonly loading = signal(false); + public readonly has_data = signal(false); +} + +describe('ReportPageComponent', () => { + let spectator: Spectator; + const createComponent = createComponentFactory(TestReportBodyComponent); + + beforeEach(() => (spectator = createComponent())); + + it('should show the spinner and no body while loading', () => { + spectator.component.loading.set(true); + spectator.component.has_data.set(true); + spectator.detectChanges(); + expect(spectator.query('mat-spinner')).toBeTruthy(); + expect(spectator.query('.report-body')).toBeFalsy(); + }); + + it('should show the empty message when there is no data', () => { + expect(spectator.query('.screen-only')).toBeTruthy(); + expect(spectator.query('.report-body')).toBeFalsy(); + }); + + it('should render the body when loaded with data', () => { + spectator.component.has_data.set(true); + spectator.detectChanges(); + expect(spectator.query('.report-body')).toBeTruthy(); + expect(spectator.query('.screen-only')).toBeFalsy(); + expect(spectator.query('mat-spinner')).toBeFalsy(); + }); +}); diff --git a/apps/concierge/src/tests/reports/spaces/report-spaces-charts.component.spec.ts b/apps/concierge/src/tests/reports/report-usage-charts.component.spec.ts similarity index 80% rename from apps/concierge/src/tests/reports/spaces/report-spaces-charts.component.spec.ts rename to apps/concierge/src/tests/reports/report-usage-charts.component.spec.ts index 73cc77dc285..12099c9e721 100644 --- a/apps/concierge/src/tests/reports/spaces/report-spaces-charts.component.spec.ts +++ b/apps/concierge/src/tests/reports/report-usage-charts.component.spec.ts @@ -3,15 +3,15 @@ import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MockProvider } from 'ng-mocks'; import { OrganisationService, SettingsService } from '@placeos/common'; -import { ReportSpacesChartsComponent } from 'apps/concierge/src/app/reports/spaces/report-spaces-charts.component'; +import { ReportUsageChartsComponent } from 'apps/concierge/src/app/reports/report-usage-charts.component'; import { ReportsStateService } from 'apps/concierge/src/app/reports/reports-state.service'; import * as chart_mod from 'chart.js'; vi.mock('chart.js'); -describe('ReportSpacesChartsComponent', () => { - let spectator: Spectator; +describe('ReportUsageChartsComponent', () => { + let spectator: Spectator; let day_list: ReturnType>; let options: ReturnType>; let counts: ReturnType>>; @@ -22,7 +22,7 @@ describe('ReportSpacesChartsComponent', () => { ]; const createComponent = createComponentFactory({ - component: ReportSpacesChartsComponent, + component: ReportUsageChartsComponent, providers: [ { provide: ReportsStateService, @@ -67,13 +67,18 @@ describe('ReportSpacesChartsComponent', () => { it('should build a daily utilisation line chart from the day list', () => { day_list.set([ - { date: new Date('2026-04-06T09:00:00').valueOf(), utilisation: '30' }, - { date: new Date('2026-04-07T09:00:00').valueOf(), utilisation: '55' }, + { + date: new Date('2026-04-06T09:00:00').valueOf(), + utilisation: '30', + }, + { + date: new Date('2026-04-07T09:00:00').valueOf(), + utilisation: '55', + }, ]); (chart_mod.Chart as unknown as any).mockClear(); spectator.component.updateDailyChart(spectator.component.day_list()); - const [, config] = (chart_mod.Chart as unknown as any).mock - .calls[0]; + const [, config] = (chart_mod.Chart as unknown as any).mock.calls[0]; expect(config.type).toBe('line'); expect(config.data.datasets[0].data).toEqual([30, 55]); }); @@ -81,12 +86,14 @@ describe('ReportSpacesChartsComponent', () => { it('should only chart levels that have bookings', () => { counts.set({ 'lvl-1': 3, 'lvl-2': 0 }); (chart_mod.Chart as unknown as any).mockClear(); - spectator.component.updateLevelChart({ zones: ['lvl-1', 'lvl-2'] }, { - 'lvl-1': 3, - 'lvl-2': 0, - }); - const [, config] = (chart_mod.Chart as unknown as any).mock - .calls[0]; + spectator.component.updateLevelChart( + { zones: ['lvl-1', 'lvl-2'] }, + { + 'lvl-1': 3, + 'lvl-2': 0, + }, + ); + const [, config] = (chart_mod.Chart as unknown as any).mock.calls[0]; expect(config.type).toBe('pie'); expect(config.data.labels).toEqual(['Level 1']); expect(config.data.datasets[0].data).toEqual([3]); diff --git a/apps/concierge/src/tests/reports/spaces/report-spaces-overall-list.component.spec.ts b/apps/concierge/src/tests/reports/report-usage-list.component.spec.ts similarity index 61% rename from apps/concierge/src/tests/reports/spaces/report-spaces-overall-list.component.spec.ts rename to apps/concierge/src/tests/reports/report-usage-list.component.spec.ts index 168ffd1828d..407bac44444 100644 --- a/apps/concierge/src/tests/reports/spaces/report-spaces-overall-list.component.spec.ts +++ b/apps/concierge/src/tests/reports/report-usage-list.component.spec.ts @@ -1,39 +1,39 @@ import { signal } from '@angular/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { IconComponent, SimpleTableComponent } from '@placeos/components'; import { MockComponent } from 'ng-mocks'; -import { IconComponent, SimpleTableComponent } from '@placeos/components'; -import { captureDownloads } from '../download-capture.helper'; -import { ReportSpacesOverallListComponent } from 'apps/concierge/src/app/reports/spaces/report-spaces-overall-list.component'; +import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; +import { ReportUsageListComponent } from 'apps/concierge/src/app/reports/report-usage-list.component'; import { ReportsStateService } from 'apps/concierge/src/app/reports/reports-state.service'; -describe('ReportSpacesOverallListComponent', () => { - let spectator: Spectator; +import { captureDownloads } from './download-capture.helper'; + +describe('ReportUsageListComponent', () => { + let spectator: Spectator; let downloads: ReturnType; let day_list: ReturnType>; const day = new Date('2026-04-06T09:00:00').valueOf(); const createComponent = createComponentFactory({ - component: ReportSpacesOverallListComponent, + component: ReportUsageListComponent, declarations: [ MockComponent(SimpleTableComponent), MockComponent(IconComponent), + MockComponent(ReportMetricGuideComponent), ], imports: [MatTooltipModule], - providers: [ - { - provide: ReportsStateService, - useValue: { day_list: (day_list = signal([])) }, - }, - ], + detectChanges: false, }); afterEach(() => downloads.restore()); beforeEach(() => { - day_list = signal([]); + day_list = signal([ + { date: day, approved: 3, count: 4, cancelled: 1, deleted: 0 }, + ]); downloads = captureDownloads(); spectator = createComponent({ providers: [ @@ -42,25 +42,26 @@ describe('ReportSpacesOverallListComponent', () => { }); }); - it('should expose the day list from the report state', () => { - const rows = [{ date: day, approved: 2, count: 5 }]; - day_list.set(rows); - expect(spectator.component.day_list()).toBe(rows); - }); - it('should format percentages for count and total', () => { expect(spectator.component.formatPercent(1, 4)).toBe('1 (25%)'); expect(spectator.component.formatPercent(0, 0)).toBe('0 (0%)'); }); - it('should export daily rows with formatted date and percentages', async () => { - day_list.set([ - { date: day, approved: 3, count: 4, cancelled: 1, deleted: 0 }, - ]); + it('should export desk rows with a short month name', async () => { + spectator.setInput('type', 'desks'); await spectator.component.download(); const csv = await downloads.text(); + expect(downloads.filename).toBe('desks-usage.csv'); + expect(csv).toContain('Apr 6, 2026(Mon)'); expect(csv).toContain('1 (25%)'); - expect(csv).toContain('0 (0%)'); + }); + + it('should export space rows with a full month name', async () => { + spectator.setInput('type', 'spaces'); + await spectator.component.download(); + const csv = await downloads.text(); expect(downloads.filename).toBe('spaces-usage.csv'); + expect(csv).toContain('April 6, 2026(Mon)'); + expect(csv).toContain('0 (0%)'); }); }); diff --git a/apps/concierge/src/tests/reports/reports.utilities.spec.ts b/apps/concierge/src/tests/reports/reports.utilities.spec.ts index 0c8b6a598a4..41ff7f3c56d 100644 --- a/apps/concierge/src/tests/reports/reports.utilities.spec.ts +++ b/apps/concierge/src/tests/reports/reports.utilities.spec.ts @@ -1,3 +1,4 @@ +import { convertToParamMap } from '@angular/router'; import { activeReportBookings, activeReportEvents, @@ -8,9 +9,37 @@ import { reportBookingDuration, reportBookingStatusStats, reportEventStatusStats, + reportQueryOptions, } from 'apps/concierge/src/app/reports/reports.utilities'; describe('report utilities', () => { + describe('reportQueryOptions', () => { + it('should read dates and the zone_ids list from the query', () => { + const params = convertToParamMap({ + start: '100', + end: '200', + zone_ids: 'lvl-1,lvl-2', + }); + expect(reportQueryOptions(params)).toEqual({ + start: 100, + end: 200, + zones: ['lvl-1', 'lvl-2'], + }); + }); + + it('should clear zones when the query has no zone list', () => { + expect(reportQueryOptions(convertToParamMap({}))).toEqual({ + zones: [], + }); + }); + + it('should keep the current zones for an empty list when dropping empty ids', () => { + const params = convertToParamMap({ zones: ',' }); + expect(reportQueryOptions(params, true)).toEqual({}); + expect(reportQueryOptions(params)).toEqual({ zones: ['', ''] }); + }); + }); + describe('noShowReportBookings', () => { it('should exclude unallocated parking bookings', () => { const booking_end = Math.floor(Date.now() / 1000) - 60; diff --git a/apps/concierge/src/tests/reports/spaces/report-spaces.component.spec.ts b/apps/concierge/src/tests/reports/spaces/report-spaces.component.spec.ts index 8164c2b5594..4f6163de6b7 100644 --- a/apps/concierge/src/tests/reports/spaces/report-spaces.component.spec.ts +++ b/apps/concierge/src/tests/reports/spaces/report-spaces.component.spec.ts @@ -1,16 +1,14 @@ import { signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; +import { MockComponent, MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; -import { AuthenticatedImageDirective } from '@placeos/components'; import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; +import { ReportUsageChartsComponent } from 'apps/concierge/src/app/reports/report-usage-charts.component'; +import { ReportUsageListComponent } from 'apps/concierge/src/app/reports/report-usage-list.component'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; import { ReportsStateService } from 'apps/concierge/src/app/reports/reports-state.service'; -import { ReportSpacesChartsComponent } from 'apps/concierge/src/app/reports/spaces/report-spaces-charts.component'; -import { ReportSpacesOverallListComponent } from 'apps/concierge/src/app/reports/spaces/report-spaces-overall-list.component'; import { ReportSpacesOverallComponent } from 'apps/concierge/src/app/reports/spaces/report-spaces-overall.component'; import { ReportSpacesSpaceListingComponent } from 'apps/concierge/src/app/reports/spaces/report-spaces-space-listing.component'; import { ReportSpacesUserListingComponent } from 'apps/concierge/src/app/reports/spaces/report-spaces-user-listing.component'; @@ -31,13 +29,11 @@ describe('ReportSpacesComponent', () => { MockComponent(ReportsOptionsComponent), MockComponent(ReportMetricGuideComponent), MockComponent(ReportSpacesOverallComponent), - MockComponent(ReportSpacesChartsComponent), - MockComponent(ReportSpacesOverallListComponent), + MockComponent(ReportUsageChartsComponent), + MockComponent(ReportUsageListComponent), MockComponent(ReportSpacesSpaceListingComponent), MockComponent(ReportSpacesUserListingComponent), - MockDirective(AuthenticatedImageDirective), ], - imports: [MatProgressSpinnerModule], providers: [ MockProvider(ReportsStateService, { stats: signal({ count: 0 }), @@ -90,16 +86,20 @@ describe('ReportSpacesComponent', () => { }); it('should apply query params to the report options', () => { - expect(set_options).toHaveBeenCalledWith({ start: 100 }); - expect(set_options).toHaveBeenCalledWith({ end: 200 }); expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, zones: ['lvl-1', 'lvl-2'], }); }); it('should clear zones when the query params omit them', () => { query_params.next(convertToParamMap({ start: '100', end: '200' })); - expect(set_options).toHaveBeenCalledWith({ zones: [] }); + expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, + zones: [], + }); }); it('should derive total_count and has_data from the report stats', () => { diff --git a/apps/concierge/src/tests/reports/visitors/visitors-report.component.spec.ts b/apps/concierge/src/tests/reports/visitors/visitors-report.component.spec.ts index 4bd73e581c2..8b4ae9981e4 100644 --- a/apps/concierge/src/tests/reports/visitors/visitors-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/visitors/visitors-report.component.spec.ts @@ -1,11 +1,9 @@ import { signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; +import { MockComponent, MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; -import { AuthenticatedImageDirective } from '@placeos/components'; import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; import { VisitorReportDailyUsageComponent } from 'apps/concierge/src/app/reports/visitors/visitor-report-daily-usage.component'; @@ -31,9 +29,7 @@ describe('VisitorsReportComponent', () => { MockComponent(VisitorReportOverallComponent), MockComponent(VisitorReportDailyUsageComponent), MockComponent(VisitorReportListComponent), - MockDirective(AuthenticatedImageDirective), ], - imports: [MatProgressSpinnerModule], providers: [ MockProvider(VisitorsReportService, { bookings: signal([]), @@ -82,16 +78,20 @@ describe('VisitorsReportComponent', () => { }); it('should apply query params to the report options', () => { - expect(set_options).toHaveBeenCalledWith({ start: 100 }); - expect(set_options).toHaveBeenCalledWith({ end: 200 }); expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, zones: ['lvl-1', 'lvl-2'], }); }); it('should clear zones when the query params omit them', () => { query_params.next(convertToParamMap({ start: '100', end: '200' })); - expect(set_options).toHaveBeenCalledWith({ zones: [] }); + expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, + zones: [], + }); }); it('should expose the booking count and has_data flag', () => { From 425bc478a32d109b80b0046179ac4aea5d81b732 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 17:07:53 +1100 Subject: [PATCH 09/21] refactor(concierge): share confirm actions, timelines and form helpers - URL, email template, event and visitor actions use confirmAction. - The day-view timelines share viewEvent and their time calculations. - timezone-field replaces four timezone autocompletes. - Shared zone scope, level selection and parking request permission. The event details modal stays open when a removal fails. --- .../building-form.component.ts | 44 +---- .../app/catering/catering-topbar.component.ts | 16 +- .../src/app/day-view/events-state.service.ts | 34 ++++ .../app/day-view/room-bookings.component.ts | 20 ++- .../room-timeline-inverted.component.ts | 154 +++++------------- .../app/day-view/room-timeline.component.ts | 154 +++++------------- .../app/day-view/room-timeline.utilities.ts | 103 +++++++++++- .../day-view/room-week-timeline.component.ts | 49 +----- .../src/app/desks/desks.component.ts | 33 ++-- .../email-templates-state.service.ts | 35 ++-- .../src/app/events/event-manage.component.ts | 46 +----- .../src/app/events/event-state.service.ts | 36 ++-- .../src/app/events/events-list.component.ts | 23 ++- .../facilities/facilities-topbar.component.ts | 10 +- .../app/lockers/locker-topbar.component.ts | 26 +-- .../src/app/parking/parking-state.service.ts | 22 +++ .../app/parking/parking-topbar.component.ts | 65 ++------ .../src/app/parking/parking.component.ts | 26 +-- .../region-manager/region-modal.component.ts | 44 +---- .../app/room-manager/room-modal.component.ts | 64 +------- .../src/app/staff/staff-topbar.component.ts | 46 ++---- apps/concierge/src/app/ui/level-selection.ts | 44 +++++ .../src/app/ui/timezone-field.component.ts | 63 +++++++ apps/concierge/src/app/ui/zone-persistence.ts | 18 ++ .../url-management/url-management.service.ts | 28 ++-- .../app/visitors/visitors-state.service.ts | 40 ++--- .../app/visitors/visitors-topbar.component.ts | 46 ++---- .../building-form.component.spec.ts | 9 - .../day-view/event-state.service.spec.ts | 36 +++- .../room-timeline-inverted.component.spec.ts | 18 +- .../day-view/room-timeline.component.spec.ts | 18 +- .../room-week-timeline.component.spec.ts | 33 ++-- .../email-templates-state.service.spec.ts | 15 +- .../events/event-manage.component.spec.ts | 12 -- .../tests/events/event-state.service.spec.ts | 36 ++++ .../parking/parking-state.service.spec.ts | 26 +++ .../tests/parking/parking.component.spec.ts | 20 +-- .../region-modal.component.spec.ts | 10 -- .../tests/ui/timezone-field.component.spec.ts | 50 ++++++ 39 files changed, 746 insertions(+), 826 deletions(-) create mode 100644 apps/concierge/src/app/ui/level-selection.ts create mode 100644 apps/concierge/src/app/ui/timezone-field.component.ts create mode 100644 apps/concierge/src/tests/ui/timezone-field.component.spec.ts diff --git a/apps/concierge/src/app/building-manager/building-form.component.ts b/apps/concierge/src/app/building-manager/building-form.component.ts index 9482119d78d..3e8892cfdeb 100644 --- a/apps/concierge/src/app/building-manager/building-form.component.ts +++ b/apps/concierge/src/app/building-manager/building-form.component.ts @@ -1,6 +1,5 @@ import { Component, - computed, effect, inject, input, @@ -10,7 +9,6 @@ import { untracked, } from '@angular/core'; import { form, FormField, required } from '@angular/forms/signals'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; @@ -23,9 +21,9 @@ import { notifyError, notifySuccess, } from '@placeos/common'; -import { TIMEZONES_IANA } from '@placeos/common/timezones'; -import { IconComponent, TranslatePipe } from '@placeos/components'; +import { TranslatePipe } from '@placeos/components'; import { addZone, authority, updateZone } from '@placeos/ts-client'; +import { TimezoneFieldComponent } from '../ui/timezone-field.component'; @Component({ selector: 'building-form', @@ -70,31 +68,10 @@ import { addZone, authority, updateZone } from '@placeos/ts-client'; />
-
- - - search - - - - @for (tz of filtered_timezones(); track tz) { - {{ tz }} - } - @if (!filtered_timezones().length) { - - {{ 'COMMON.TIMEZONE_EMPTY' | translate }} - - } - -
+
@@ -320,58 +311,39 @@ export class RoomBookingsInvertedTimelineComponent ); public readonly show_time = computed(() => { this._building(); - const date = this.date(); - const today = isSameDay(date, Date.now()); - const offset = this.timezone - ? getTimezoneDifferenceInHours(this.timezone) - : 0; - const start = addHours( - setHours(startOfDay(Date.now()), this.block_start), - -offset, - ).valueOf(); - const end = addHours( - setHours(startOfDay(Date.now()), this.block_end), - -offset, - ).valueOf(); - return today && Date.now() >= start && Date.now() <= end; + const now = Date.now(); + const { start, end } = timelineWindow( + now, + this.block_start, + this.block_end, + this.timezone, + ); + return isSameDay(this.date(), now) && now >= start && now <= end; }); public readonly events = computed(() => { - const spaces = this.spaces(); - const events = this._filtered().filter(isActiveRoomTimelineEvent); - const date = this.date(); - const offset = this.timezone - ? getTimezoneDifferenceInHours(this.timezone) - : 0; - const start = addHours( - setHours(startOfDay(date), this.block_start), - -offset, - ).valueOf(); - const end = addHours( - setHours(startOfDay(date), this.block_end), - -offset, - ).valueOf(); + const window = timelineWindow( + this.date(), + this.block_start, + this.block_end, + this.timezone, + ); // HashMap keeps the loose template types of the old `{}` map - const event_map: HashMap = groupRoomTimelineEvents( - spaces, - events.filter( - (event) => event.date_end >= start && event.date <= end, - ), + const event_map: HashMap = timelineEvents( + this.spaces(), + this._filtered(), + window, ); return event_map; }); - private _hour_list = Array.from({ length: 24 }, (_, i) => i); public readonly hours = computed(() => - this._hour_list.filter( - (hour) => hour >= this.block_start && hour < this.block_end, - ), + timelineHours(this.block_start, this.block_end), ); public get now() { return startOfMinute(Date.now()).valueOf(); } - public readonly edit = (e) => this._state.newBooking(e); public readonly setDate = (d) => this._state.setDate(d); private _local_tz = getTimezoneOffsetString( @@ -408,19 +380,14 @@ export class RoomBookingsInvertedTimelineComponent } public formatHour(hour: number) { - const date = setHours(Date.now(), hour); - return this._settings.get('app.use_24_hour_time') - ? format(date, 'HH:00') - : format(date, 'h a'); + return timelineHourLabel( + hour, + !!this._settings.get('app.use_24_hour_time'), + ); } - private _date_pipe = new DatePipe('en'); - public eventTooltip(event: CalendarEvent) { - const tooltip = `Start: ${event.all_day ? 'All Day' : this._date_pipe.transform(event.date, this.time_format)} -Title: ${event.title} -Host: ${event.organiser?.name || event.host}`; - return tooltip; + return timelineTooltip(event, this.time_format); } public ngOnInit() { @@ -437,53 +404,22 @@ Host: ${event.organiser?.name || event.host}`; } public timeToOffset(date: number) { - const current_tz = Intl.DateTimeFormat().resolvedOptions().timeZone; - const offset = !this.timezone - ? 0 - : getTimezoneDifferenceInHours(this.timezone, current_tz); - const start_time = setHours( - startOfDay(this._state.getDate()), - this.block_start - offset, + return timelineOffset( + date, + this._state.getDate(), + this.block_start, + this.block_range, + this.timezone, ); - const diff = differenceInMinutes(date, start_time); - - return +((Math.max(0, diff / 60) / this.block_range) * 100).toFixed(2); } public endToOffset(duration: number) { - return +( - (Math.min(this.block_range, duration / 60) / this.block_range) * - 100 - ).toFixed(2); + return timelineLength(duration, this.block_range); } - public viewEvent( - event: CalendarEvent, - space_id: string, - _scroll_to = false, - ) { - if (event.is_system_event) return; - const ref = this._dialog.open(EventDetailsModalComponent, { - data: { - event, - edit_fn: (e) => this.edit(e), - remove_fn: (e) => this.remove(e, space_id), - }, - }); - ref.componentInstance.hide_edit.set( - !this._settings.get('app.events.allow_edit'), - ); - this.subscription( - 'actions', - ref.componentInstance.action.subscribe(async (action) => { - if (!action.includes('breakdown')) return; - const ref = this._dialog.open(SetupBreakdownModalComponent, { - data: event, - }); - const data = await nextValueFrom(ref.afterClosed()); - if (data) this._state.replace(data); - }), - ); + /** Open the details of a booking shown in the column of `space_id` */ + public viewEvent(event: CalendarEvent, space_id: string) { + this._state.viewEvent(event, (e) => this.remove(e, space_id)); } public async remove(item: CalendarEvent, space_id: string) { diff --git a/apps/concierge/src/app/day-view/room-timeline.component.ts b/apps/concierge/src/app/day-view/room-timeline.component.ts index 76ee3e423fc..c685c0a96d0 100644 --- a/apps/concierge/src/app/day-view/room-timeline.component.ts +++ b/apps/concierge/src/app/day-view/room-timeline.component.ts @@ -1,4 +1,4 @@ -import { CommonModule, DatePipe } from '@angular/common'; +import { CommonModule } from '@angular/common'; import { Component, OnInit, computed, inject } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatDialog } from '@angular/material/dialog'; @@ -8,35 +8,26 @@ import { CalendarEvent, OrganisationService, SettingsService, - getTimezoneDifferenceInHours, getTimezoneOffsetString, HashMap, - nextValueFrom, notifyError, notifyInfo, notifySuccess, } from '@placeos/common'; import { TranslatePipe, openConfirmModal } from '@placeos/components'; -import { - EventDetailsModalComponent, - SetupBreakdownModalComponent, - declineEvent, -} from '@placeos/events'; +import { declineEvent } from '@placeos/events'; import { DateOptionsComponent } from '@placeos/form-fields'; -import { - addHours, - differenceInMinutes, - format, - isSameDay, - setHours, - startOfDay, - startOfMinute, -} from 'date-fns'; +import { format, isSameDay, startOfMinute } from 'date-fns'; import { EventsStateService } from './events-state.service'; import { RoomBookingSearchComponent } from './room-booking-search.component'; import { - groupRoomTimelineEvents, - isActiveRoomTimelineEvent, + timelineEvents, + timelineHourLabel, + timelineHours, + timelineLength, + timelineOffset, + timelineTooltip, + timelineWindow, } from './room-timeline.utilities'; @Component({ @@ -67,7 +58,7 @@ import { }
@@ -273,58 +264,39 @@ export class RoomBookingsTimelineComponent ); public readonly show_time = computed(() => { this._building(); - const date = this.date(); - const today = isSameDay(date, Date.now()); - const offset = this.timezone - ? getTimezoneDifferenceInHours(this.timezone) - : 0; - const start = addHours( - setHours(startOfDay(Date.now()), this.block_start), - -offset, - ).valueOf(); - const end = addHours( - setHours(startOfDay(Date.now()), this.block_end), - -offset, - ).valueOf(); - return today && Date.now() >= start && Date.now() <= end; + const now = Date.now(); + const { start, end } = timelineWindow( + now, + this.block_start, + this.block_end, + this.timezone, + ); + return isSameDay(this.date(), now) && now >= start && now <= end; }); public readonly events = computed(() => { - const spaces = this.spaces(); - const events = this._filtered().filter(isActiveRoomTimelineEvent); - const date = this.date(); - const offset = this.timezone - ? getTimezoneDifferenceInHours(this.timezone) - : 0; - const start = addHours( - setHours(startOfDay(date), this.block_start), - -offset, - ).valueOf(); - const end = addHours( - setHours(startOfDay(date), this.block_end), - -offset, - ).valueOf(); + const window = timelineWindow( + this.date(), + this.block_start, + this.block_end, + this.timezone, + ); // HashMap keeps the loose template types of the old `{}` map - const event_map: HashMap = groupRoomTimelineEvents( - spaces, - events.filter( - (event) => event.date_end >= start && event.date <= end, - ), + const event_map: HashMap = timelineEvents( + this.spaces(), + this._filtered(), + window, ); return event_map; }); - private _hour_list = Array.from({ length: 24 }, (_, i) => i); public readonly hours = computed(() => - this._hour_list.filter( - (hour) => hour >= this.block_start && hour < this.block_end, - ), + timelineHours(this.block_start, this.block_end), ); public get now() { return startOfMinute(Date.now()).valueOf(); } - public readonly edit = (e) => this._state.newBooking(e); public readonly setDate = (d) => this._state.setDate(d); private _local_tz = getTimezoneOffsetString( @@ -365,19 +337,14 @@ export class RoomBookingsTimelineComponent } public formatHour(hour: number) { - const date = setHours(Date.now(), hour); - return this._settings.get('app.use_24_hour_time') - ? format(date, 'HH:00') - : format(date, 'h a'); + return timelineHourLabel( + hour, + !!this._settings.get('app.use_24_hour_time'), + ); } - private _date_pipe = new DatePipe('en'); - public eventTooltip(event: CalendarEvent) { - const tooltip = `Start: ${event.all_day ? 'All Day' : this._date_pipe.transform(event.date, this.time_format)} -Title: ${event.title} -Host: ${event.organiser?.name || event.host}`; - return tooltip; + return timelineTooltip(event, this.time_format); } public ngOnInit() { @@ -385,53 +352,22 @@ Host: ${event.organiser?.name || event.host}`; } public timeToOffset(date: number) { - const current_tz = Intl.DateTimeFormat().resolvedOptions().timeZone; - const offset = !this.timezone - ? 0 - : getTimezoneDifferenceInHours(this.timezone, current_tz); - const start_time = setHours( - startOfDay(this._state.getDate()), - this.block_start - offset, + return timelineOffset( + date, + this._state.getDate(), + this.block_start, + this.block_range, + this.timezone, ); - const diff = differenceInMinutes(date, start_time); - - return +((Math.max(0, diff / 60) / this.block_range) * 100).toFixed(2); } public endToOffset(duration: number) { - return +( - (Math.min(this.block_range, duration / 60) / this.block_range) * - 100 - ).toFixed(2); + return timelineLength(duration, this.block_range); } - public viewEvent( - event: CalendarEvent, - space_id: string, - _scroll_to = false, - ) { - if (event.is_system_event) return; - const ref = this._dialog.open(EventDetailsModalComponent, { - data: { - event, - edit_fn: (e) => this.edit(e), - remove_fn: (e) => this.remove(e, space_id), - }, - }); - ref.componentInstance.hide_edit.set( - !this._settings.get('app.events.allow_edit'), - ); - this.subscription( - 'actions', - ref.componentInstance.action.subscribe(async (action) => { - if (!action.includes('breakdown')) return; - const ref = this._dialog.open(SetupBreakdownModalComponent, { - data: event, - }); - const data = await nextValueFrom(ref.afterClosed()); - if (data) this._state.replace(data); - }), - ); + /** Open the details of a booking shown in the column of `space_id` */ + public viewEvent(event: CalendarEvent, space_id: string) { + this._state.viewEvent(event, (e) => this.remove(e, space_id)); } public async remove(item: CalendarEvent, space_id: string) { diff --git a/apps/concierge/src/app/day-view/room-timeline.utilities.ts b/apps/concierge/src/app/day-view/room-timeline.utilities.ts index e4c5a544efb..ccfe1cff874 100644 --- a/apps/concierge/src/app/day-view/room-timeline.utilities.ts +++ b/apps/concierge/src/app/day-view/room-timeline.utilities.ts @@ -1,4 +1,12 @@ -import { CalendarEvent } from '@placeos/common'; +import { DatePipe } from '@angular/common'; +import { CalendarEvent, getTimezoneDifferenceInHours } from '@placeos/common'; +import { + addHours, + differenceInMinutes, + format, + setHours, + startOfDay, +} from 'date-fns'; export function isActiveRoomTimelineEvent(event: CalendarEvent): boolean { const state = (event as any).state; @@ -59,3 +67,96 @@ export function groupRoomTimelineEvents( } return event_map; } + +/** Time range of the shown hours, as Unix milliseconds */ +export interface TimelineWindow { + start: number; + end: number; +} + +/** + * Start and end of the shown hours on the day of `date`. When `timezone` is + * set, the hours are in that timezone. + */ +export function timelineWindow( + date: number, + block_start: number, + block_end: number, + timezone: string, +): TimelineWindow { + const offset = timezone ? getTimezoneDifferenceInHours(timezone) : 0; + const day = startOfDay(date); + return { + start: addHours(setHours(day, block_start), -offset).valueOf(), + end: addHours(setHours(day, block_end), -offset).valueOf(), + }; +} + +/** Group the active events that overlap `window` by the spaces they book. */ +export function timelineEvents( + spaces: TimelineSpace[], + events: CalendarEvent[], + window: TimelineWindow, +): Record { + return groupRoomTimelineEvents( + spaces, + events.filter( + (event) => + isActiveRoomTimelineEvent(event) && + event.date_end >= window.start && + event.date <= window.end, + ), + ); +} + +/** Hours of the day from `block_start` up to, but not including, `block_end`. */ +export function timelineHours(block_start: number, block_end: number) { + return Array.from({ length: 24 }, (_, i) => i).filter( + (hour) => hour >= block_start && hour < block_end, + ); +} + +/** + * Position of `date` as a percent of the shown hours on the day of `day`. + * When `timezone` is set, the hours are in that timezone. + */ +export function timelineOffset( + date: number, + day: number, + block_start: number, + block_range: number, + timezone: string, +): number { + const current_tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + const offset = timezone + ? getTimezoneDifferenceInHours(timezone, current_tz) + : 0; + const start_time = setHours(startOfDay(day), block_start - offset); + const diff = differenceInMinutes(date, start_time); + return +((Math.max(0, diff / 60) / block_range) * 100).toFixed(2); +} + +/** Length of `duration` minutes as a percent of the shown hours. */ +export function timelineLength(duration: number, block_range: number): number { + return +( + (Math.min(block_range, duration / 60) / block_range) * + 100 + ).toFixed(2); +} + +/** Label for an hour on the timeline axis, such as "09:00" or "9 AM". */ +export function timelineHourLabel(hour: number, use_24_hour: boolean): string { + return format(setHours(Date.now(), hour), use_24_hour ? 'HH:00' : 'h a'); +} + +const DATE_PIPE = new DatePipe('en'); + +/** Tooltip text for a booking on the timeline. */ +export function timelineTooltip( + event: CalendarEvent, + time_format: string, +): string { + return `Start: ${event.all_day ? 'All Day' : DATE_PIPE.transform(event.date, time_format)} +Title: ${event.title} +Host: ${event.organiser?.name || event.host}`; +} diff --git a/apps/concierge/src/app/day-view/room-week-timeline.component.ts b/apps/concierge/src/app/day-view/room-week-timeline.component.ts index 3fb91e93a53..a1a457db6e2 100644 --- a/apps/concierge/src/app/day-view/room-week-timeline.component.ts +++ b/apps/concierge/src/app/day-view/room-week-timeline.component.ts @@ -1,22 +1,16 @@ import { CommonModule, DatePipe } from '@angular/common'; import { Component, computed, inject, OnInit, signal } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; -import { MatDialog } from '@angular/material/dialog'; import { AsyncHandler, CalendarEvent, getTimezoneOffsetInMinutes, getTimezoneOffsetString, i18n, - nextValueFrom, OrganisationService, SettingsService, } from '@placeos/common'; import { TranslatePipe } from '@placeos/components'; -import { - EventDetailsModalComponent, - SetupBreakdownModalComponent, -} from '@placeos/events'; import { DateOptionsComponent } from '@placeos/form-fields'; import { UserPipe } from '@placeos/users'; import { @@ -62,7 +56,7 @@ import { isActiveRoomTimelineEvent } from './room-timeline.utilities'; }
@@ -126,13 +120,7 @@ import { isActiveRoomTimelineEvent } from './room-timeline.utilities';
} -
- - - search - - - - @for (tz of filtered_timezones(); track tz) { - - {{ tz }} - - } - @if (!filtered_timezones().length) { - - {{ - 'COMMON.TIMEZONE_EMPTY' | translate - }} - - } - -
+

@@ -559,7 +531,7 @@ const EMPTY = []; MatSelectModule, MatInputModule, MatChipsModule, - MatAutocompleteModule, + TimezoneFieldComponent, FormsModule, FormField, SettingsToggleComponent, @@ -595,12 +567,6 @@ export class EventManageComponent public readonly tag_list = computed( () => this._form_value()?.tags || EMPTY, ); - public readonly filtered_timezones = computed(() => { - const timezone = this._form_value()?.timezone || ''; - return TIMEZONES_IANA.filter((_) => - _.toLowerCase().includes(timezone.toLowerCase()), - ); - }); public readonly start_date = computed(() => this._form_value()?.date); public get max_duration() { diff --git a/apps/concierge/src/app/events/event-state.service.ts b/apps/concierge/src/app/events/event-state.service.ts index 667697f710b..36d908eba67 100644 --- a/apps/concierge/src/app/events/event-state.service.ts +++ b/apps/concierge/src/app/events/event-state.service.ts @@ -8,16 +8,15 @@ import { OrganisationService, SettingsService, i18n, - notifyError, notifySuccess, } from '@placeos/common'; -import { openConfirmModal } from '@placeos/components'; import { GroupEventDetailsModalComponent, queryEvents, removeEvent, } from '@placeos/events'; import { endOfDay, format, getUnixTime, startOfDay } from 'date-fns'; +import { confirmAction, errorText } from '../ui/modal-actions'; export interface GroupEventOptions { period: 'week' | 'month'; @@ -163,15 +162,16 @@ export class EventStateService extends AsyncHandler { event.id, ]), remove_fn: async () => { - await this.removeEvent(event); - ref.close(); + if (await this.removeEvent(event)) ref.close(); }, }, }); } - public async removeEvent(event: CalendarEvent) { - const result = await openConfirmModal( + /** Ask, then remove the event. Resolves true when it was removed. */ + public async removeEvent(event: CalendarEvent): Promise { + const removed = await confirmAction( + this._dialog, { title: i18n('APP.CONCIERGE.EVENTS_REMOVE_TITLE'), content: i18n('APP.CONCIERGE.EVENTS_REMOVE_MSG', { @@ -180,21 +180,19 @@ export class EventStateService extends AsyncHandler { icon: { content: 'delete' }, confirm_text: i18n('COMMON.REMOVE'), }, - this._dialog, + { + loading: i18n('APP.CONCIERGE.EVENTS_REMOVE_LOADING'), + action: () => + removeEvent(event.id, { calendar: this.calendar }), + error: (e) => + i18n('APP.CONCIERGE.EVENTS_REMOVE_ERROR', { + error: errorText(e), + }), + }, ); - if (result.reason !== 'done') return; - result.loading(i18n('APP.CONCIERGE.EVENTS_REMOVE_LOADING')); - await removeEvent(event.id, { - calendar: this.calendar, - }).catch((e) => { - notifyError( - i18n('APP.CONCIERGE.EVENTS_REMOVE_ERROR', { error: e }), - ); - result.close(); - throw e; - }); - result.close(); + if (!removed) return false; notifySuccess(i18n('APP.CONCIERGE.EVENTS_REMOVE_SUCCESS')); this._changed.set(Date.now()); + return true; } } diff --git a/apps/concierge/src/app/events/events-list.component.ts b/apps/concierge/src/app/events/events-list.component.ts index 8fa2174229e..e86671ce7e0 100644 --- a/apps/concierge/src/app/events/events-list.component.ts +++ b/apps/concierge/src/app/events/events-list.component.ts @@ -32,7 +32,11 @@ import { startOfWeek, subMonths, } from 'date-fns'; -import { loadPersistedZones, persistZones } from '../ui/zone-persistence'; +import { + loadPersistedZones, + persistZones, + zoneScopeId, +} from '../ui/zone-persistence'; import { EventCalendarComponent } from './event-calendar.component'; import { EventListingComponent } from './event-listing.component'; import { EventStateService } from './event-state.service'; @@ -229,7 +233,10 @@ export class EventsListComponent extends AsyncHandler implements OnInit { // No selection yet: restore persisted zones (if any). const persisted = loadPersistedZones( 'events', - this._persistScopeId(), + zoneScopeId( + this._org, + !!this._settings.get('app.use_region'), + ), ).filter((zone) => levels.find((lvl) => lvl.id === zone)); if (persisted.length) this._applyZones(persisted, true); }); @@ -313,16 +320,14 @@ export class EventsListComponent extends AsyncHandler implements OnInit { }, queryParamsHandling: 'merge', }); - persistZones('events', this._persistScopeId(), zones); + persistZones( + 'events', + zoneScopeId(this._org, !!this._settings.get('app.use_region')), + zones, + ); } } - private _persistScopeId() { - return this._settings.get('app.use_region') - ? this._org.region?.id || '' - : this._org.building?.id || ''; - } - public setView(view: 'list' | 'calendar') { this.view.set(view); this._router.navigate([], { diff --git a/apps/concierge/src/app/facilities/facilities-topbar.component.ts b/apps/concierge/src/app/facilities/facilities-topbar.component.ts index 61f10f3b2bc..b6960e94381 100644 --- a/apps/concierge/src/app/facilities/facilities-topbar.component.ts +++ b/apps/concierge/src/app/facilities/facilities-topbar.component.ts @@ -9,6 +9,7 @@ import { AsyncHandler, Identity, OrganisationService } from '@placeos/common'; import { TranslatePipe } from '@placeos/components'; import { EventsStateService } from '../day-view/events-state.service'; import { SearchbarComponent } from '../ui/searchbar.component'; +import { sameZones } from '../ui/zone-persistence'; @Component({ selector: 'facilities-topbar', @@ -142,7 +143,7 @@ export class FacilitiesTopbarComponent extends AsyncHandler implements OnInit { if (!zones.length) { zones.push(levels[0].id); } - if (this._same_zones(zones, this.zones())) return; + if (sameZones(zones, this.zones())) return; this.updateZones(zones); }); } @@ -152,11 +153,4 @@ export class FacilitiesTopbarComponent extends AsyncHandler implements OnInit { this._ready.set(true); this.updateTypes(this.type_list()); } - - private _same_zones(left: string[], right: string[]) { - return ( - left.length === right.length && - left.every((zone, index) => zone === right[index]) - ); - } } diff --git a/apps/concierge/src/app/lockers/locker-topbar.component.ts b/apps/concierge/src/app/lockers/locker-topbar.component.ts index 86b3ba3d409..263b38412d2 100644 --- a/apps/concierge/src/app/lockers/locker-topbar.component.ts +++ b/apps/concierge/src/app/lockers/locker-topbar.component.ts @@ -30,7 +30,12 @@ import { import { DateOptionsComponent } from '@placeos/form-fields'; import { BookingRulesModalComponent } from '../ui/booking-rules-modal.component'; import { SearchbarComponent } from '../ui/searchbar.component'; -import { loadPersistedZones, persistZones } from '../ui/zone-persistence'; +import { + loadPersistedZones, + persistZones, + sameZones, + zoneScopeId, +} from '../ui/zone-persistence'; import { LockerStateService } from './locker-state.service'; @Component({ @@ -235,7 +240,7 @@ export class LockersTopbarComponent extends AsyncHandler implements OnInit { this._state.setFilters({ zones }); persistZones( this.path() === 'manage' ? 'lockers-manage' : 'lockers', - this._persistScopeId(), + zoneScopeId(this._org, this.use_region), zones, ); }; @@ -292,20 +297,14 @@ export class LockersTopbarComponent extends AsyncHandler implements OnInit { // selection means all levels. zones = loadPersistedZones( this.path() === 'manage' ? 'lockers-manage' : 'lockers', - this._persistScopeId(), + zoneScopeId(this._org, this.use_region), ).filter((zone) => levels.find((lvl) => lvl.id === zone)); } - if (this._same_zones(zones, this.zones())) return; + if (sameZones(zones, this.zones())) return; this.updateZones(zones); }); } - private _persistScopeId() { - return this.use_region - ? this._org.region?.id || '' - : this._org.building?.id || ''; - } - private _updatePath() { const path = this.path(); if (this._previous_path && this._previous_path !== path) { @@ -329,11 +328,4 @@ export class LockersTopbarComponent extends AsyncHandler implements OnInit { allow_time_changes: true, }); } - - private _same_zones(left: string[], right: string[]) { - return ( - left.length === right.length && - left.every((zone, index) => zone === right[index]) - ); - } } diff --git a/apps/concierge/src/app/parking/parking-state.service.ts b/apps/concierge/src/app/parking/parking-state.service.ts index 203722bdf0d..593308f73aa 100644 --- a/apps/concierge/src/app/parking/parking-state.service.ts +++ b/apps/concierge/src/app/parking/parking-state.service.ts @@ -611,6 +611,28 @@ export class ParkingStateService extends AsyncHandler { ); } + /** + * Whether the current user can see parking requests. Needs the + * `app.parking.show_requests` setting. Admins can always see them. Other + * users must be in a `parking-requests` feature group, when that list + * has groups. + */ + public get can_view_requests(): boolean { + if (!this._settings.get('app.parking.show_requests')) return false; + const feature_groups = this._settings.get('app.feature_groups') || {}; + const request_groups: string[] = + feature_groups['parking-requests'] || []; + const admin_group = this._settings.get('app.admin_group') || 'admin'; + const groups = currentUser()?.groups || []; + return ( + groups.includes(admin_group) || + groups.includes('placeos_admin') || + groups.includes('placeos_support') || + !request_groups.length || + groups.some((grp) => request_groups.includes(grp)) + ); + } + public filterEventList(list: Booking[], filter_type: ParkingRequestFilter) { const show_requests = !!this._settings.get('app.parking.show_requests'); const visible_list = show_requests diff --git a/apps/concierge/src/app/parking/parking-topbar.component.ts b/apps/concierge/src/app/parking/parking-topbar.component.ts index 8e610322998..9cde5776b6b 100644 --- a/apps/concierge/src/app/parking/parking-topbar.component.ts +++ b/apps/concierge/src/app/parking/parking-topbar.component.ts @@ -11,7 +11,6 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { AsyncHandler, - currentUser, OrganisationService, SettingsService, } from '@placeos/common'; @@ -31,7 +30,12 @@ import { import { DateOptionsComponent } from '@placeos/form-fields'; import { BookingRulesModalComponent } from '../ui/booking-rules-modal.component'; import { SearchbarComponent } from '../ui/searchbar.component'; -import { loadPersistedZones, persistZones } from '../ui/zone-persistence'; +import { + loadPersistedZones, + persistZones, + sameZones, + zoneScopeId, +} from '../ui/zone-persistence'; import { ParkingRequestFilter, ParkingStateService, @@ -503,11 +507,8 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { const zone_param = zones.length ? zones.join(',') : null; const query_zone_param = this._query_params().get('zone_ids') || null; const query_matches = zone_param === query_zone_param; - const selected_zones_match = this._sameZones(zones, this.zones()); - const option_zones_match = this._sameZones( - zones, - this.options().zones || [], - ); + const selected_zones_match = sameZones(zones, this.zones()); + const option_zones_match = sameZones(zones, this.options().zones || []); if (query_matches && selected_zones_match && option_zones_match) { return; } @@ -525,7 +526,7 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { if (!selector_inactive) { persistZones( this.section() === 'manage' ? 'parking-manage' : 'parking', - this._persistScopeId(), + zoneScopeId(this._org, this.use_region), zones, ); } @@ -574,18 +575,7 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { } public get can_view_requests() { - if (!this._settings.get('app.parking.show_requests')) return false; - const feature_groups = this._settings.get('app.feature_groups') || {}; - const request_groups = feature_groups['parking-requests'] || []; - const admin_group = this._settings.get('app.admin_group') || 'admin'; - const groups = currentUser()?.groups || []; - return ( - groups.includes(admin_group) || - groups.includes('placeos_admin') || - groups.includes('placeos_support') || - !request_groups.length || - groups.some((grp) => request_groups.includes(grp)) - ); + return this._state.can_view_requests; } public manageRestrictions() { @@ -662,10 +652,10 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { // selection means all levels. zones = loadPersistedZones( this.section() === 'manage' ? 'parking-manage' : 'parking', - this._persistScopeId(), + zoneScopeId(this._org, this.use_region), ).filter((zone) => levels.find((lvl) => lvl.id === zone)); } - if (this._sameZones(zones, this.zones())) return; + if (sameZones(zones, this.zones())) return; this.updateZones(zones); }); effect(() => { @@ -683,22 +673,16 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { if (has_valid_zone) return; const persisted = loadPersistedZones( 'parking', - this._persistScopeId(), + zoneScopeId(this._org, this.use_region), ).filter((zone) => levels.find((lvl) => lvl.id === zone)); this.updateZones([persisted[0] || levels[0].id]); }); } - private _persistScopeId() { - return this.use_region - ? this._org.region?.id || '' - : this._org.building?.id || ''; - } - private _applyQueryZones(zones: string[]) { if ( - this._sameZones(zones, this.zones()) && - this._sameZones(zones, this.options().zones || []) + sameZones(zones, this.zones()) && + sameZones(zones, this.options().zones || []) ) { return; } @@ -775,16 +759,8 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { } if (section_changed) this.zones.set([]); this._previous_route_key = route_key; - if (current_view === 'bookings') { - this.setRequestFilter('bookings'); - } else if (current_view === 'requests' && this.can_view_requests) { - this.setRequestFilter('requests'); - } - if ( - this.section() === 'events' && - (!this.can_view_requests || - !this._settings.get('app.parking.show_requests')) - ) { + // ParkingComponent maps the bookings and requests routes to a filter. + if (this.section() === 'events' && !this.can_view_requests) { this.setRequestFilter('bookings'); } if ( @@ -804,11 +780,4 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { this.updateZones([]); } } - - private _sameZones(first: string[], second: string[]) { - return ( - first.length === second.length && - first.every((zone, index) => zone === second[index]) - ); - } } diff --git a/apps/concierge/src/app/parking/parking.component.ts b/apps/concierge/src/app/parking/parking.component.ts index d9c7c06c293..ce0414fefd8 100644 --- a/apps/concierge/src/app/parking/parking.component.ts +++ b/apps/concierge/src/app/parking/parking.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { MatTabsModule } from '@angular/material/tabs'; import { NavigationEnd, Router, RouterModule } from '@angular/router'; -import { AsyncHandler, SettingsService, currentUser } from '@placeos/common'; +import { AsyncHandler, SettingsService } from '@placeos/common'; import { TranslatePipe } from '@placeos/components'; import { ApplicationSidebarComponent } from '../ui/app-sidebar.component'; import { ParkingStateService } from './parking-state.service'; @@ -156,10 +156,6 @@ export class ParkingComponent extends AsyncHandler implements OnInit { 'bookings' | 'fleet' | 'list' | 'map' | 'requests' | 'spaces' | 'users' >('list'); - public get show_requests() { - return !!this._settings.get('app.parking.show_requests'); - } - public get hide_users_and_vehicles() { return !!this._settings.get('app.parking.hide_users_and_vehicles'); } @@ -178,26 +174,8 @@ export class ParkingComponent extends AsyncHandler implements OnInit { ); } - public get is_admin() { - const groups = currentUser().groups || []; - const admin_group = this._settings.get('app.admin_group') || 'admin'; - return ( - groups.includes(admin_group) || - groups.includes('placeos_admin') || - groups.includes('placeos_support') - ); - } - public get can_view_requests() { - if (!this.show_requests) return false; - const feature_groups = this._settings.get('app.feature_groups') || {}; - const request_groups = feature_groups['parking-requests'] || []; - const groups = currentUser().groups || []; - return ( - this.is_admin || - !request_groups.length || - groups.some((grp) => request_groups.includes(grp)) - ); + return this._state.can_view_requests; } public ngOnInit() { diff --git a/apps/concierge/src/app/region-manager/region-modal.component.ts b/apps/concierge/src/app/region-manager/region-modal.component.ts index d62c8828051..799fc99cadc 100644 --- a/apps/concierge/src/app/region-manager/region-modal.component.ts +++ b/apps/concierge/src/app/region-manager/region-modal.component.ts @@ -1,6 +1,5 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { form, FormField, required } from '@angular/forms/signals'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -11,14 +10,13 @@ import { OrganisationService, Region, } from '@placeos/common'; -import { TIMEZONES_IANA } from '@placeos/common/timezones'; import { FullscreenModalShellComponent, - IconComponent, TranslatePipe, } from '@placeos/components'; import { addZone, authority, updateZone } from '@placeos/ts-client'; import { errorText } from '../ui/modal-actions'; +import { TimezoneFieldComponent } from '../ui/timezone-field.component'; @Component({ selector: 'region-modal', @@ -49,42 +47,20 @@ import { errorText } from '../ui/modal-actions'; />

-
- - - search - - - - @for (tz of filtered_timezones(); track tz) { - {{ tz }} - } - @if (!filtered_timezones().length) { - - {{ 'COMMON.TIMEZONE_EMPTY' | translate }} - - } - -
+ `, styles: [``], imports: [ FullscreenModalShellComponent, - MatAutocompleteModule, MatFormFieldModule, MatInputModule, TranslatePipe, - IconComponent, + TimezoneFieldComponent, FormField, ], }) @@ -95,7 +71,6 @@ export class RegionModalComponent extends AsyncHandler { inject>(MatDialogRef); public readonly loading = signal(false); - public readonly timezones = TIMEZONES_IANA; public readonly model = signal({ id: this._data?.id || '', @@ -111,11 +86,6 @@ export class RegionModalComponent extends AsyncHandler { required(p.display_name); }); - public readonly filtered_timezones = computed(() => { - const timezone = (this.model().timezone || '').toLowerCase(); - return this.timezones.filter((_) => _.toLowerCase().includes(timezone)); - }); - public async save() { this.form().markAsTouched(); if (!this.form().valid()) { diff --git a/apps/concierge/src/app/room-manager/room-modal.component.ts b/apps/concierge/src/app/room-manager/room-modal.component.ts index 8108206af9a..ed0e358d7be 100644 --- a/apps/concierge/src/app/room-manager/room-modal.component.ts +++ b/apps/concierge/src/app/room-manager/room-modal.component.ts @@ -1,12 +1,5 @@ import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes'; -import { - Component, - Injector, - OnInit, - computed, - inject, - signal, -} from '@angular/core'; +import { Component, Injector, OnInit, inject, signal } from '@angular/core'; import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips'; import { addSystem, @@ -17,7 +10,6 @@ import { import { CommonModule } from '@angular/common'; import { FormField, form } from '@angular/forms/signals'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MAT_DIALOG_DATA, MatDialog, @@ -36,11 +28,9 @@ import { i18n, notifyError, notifyWarn, - onFieldChange, patchSignalModel, unique, } from '@placeos/common'; -import { TIMEZONES_IANA } from '@placeos/common/timezones'; import { FullscreenModalShellComponent, IconComponent, @@ -55,6 +45,7 @@ import { } from '@placeos/form-fields'; import { errorText } from '../ui/modal-actions'; import { SelectMapItemModalComponent } from '../ui/select-map-item-modal.component'; +import { TimezoneFieldComponent } from '../ui/timezone-field.component'; @Component({ selector: 'room-form-modal', @@ -374,33 +365,10 @@ import { SelectMapItemModalComponent } from '../ui/select-map-item-modal.compone
} -
- - - search - - - - @for (tz of filtered_timezones(); track tz) { - - {{ tz }} - - } - @if (!filtered_timezones().length) { - - {{ 'COMMON.TIMEZONE_EMPTY' | translate }} - - } - -
+ @if (form.images) {
@@ -460,34 +470,10 @@ const UNTAGGED = '\0untagged';
- @for ( - tag of visibleTags(media_item); - track tag - ) { - - {{ tag }} - - } - @if ( - remainingTagCount(media_item) > 0 - ) { - - +{{ - remainingTagCount( - media_item - ) - }} - - } +
}
@@ -780,6 +766,7 @@ const UNTAGGED = '\0untagged'; IntersectDirective, MediaThumbnailComponent, MediaSharedGroupsComponent, + MediaTagChipsComponent, ], }) export class MediaListComponent implements OnInit { @@ -984,18 +971,6 @@ export class MediaListComponent implements OnInit { } } - public visibleTags(item: SignageMedia): string[] { - return (item.tags || []).slice(0, 2); - } - - public remainingTags(item: SignageMedia): string[] { - return (item.tags || []).slice(2); - } - - public remainingTagCount(item: SignageMedia): number { - return this.remainingTags(item).length; - } - public readonly previewItem = (item: SignageMedia) => this._media_service.previewMedia(item); @@ -1060,8 +1035,4 @@ export class MediaListComponent implements OnInit { public readonly can_update_media_tags = this._context.can_update_media_tags; public readonly can_delete = this._context.can_delete; public readonly can_share = this._context.can_share; - - public drop(_event: CdkDragDrop) { - // No-op for media list drops - } } diff --git a/apps/signage-manager/src/app/media/playlist-sidebar.component.ts b/apps/signage-manager/src/app/media/playlist-sidebar.component.ts index e76e15629f5..0ea624f5cd7 100644 --- a/apps/signage-manager/src/app/media/playlist-sidebar.component.ts +++ b/apps/signage-manager/src/app/media/playlist-sidebar.component.ts @@ -15,6 +15,7 @@ import { import { SignageMedia, SignagePlaylist } from '@placeos/ts-client'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; import { IntersectDirective } from '../shared/intersect.directive'; +import { PlaylistStatusBadgeComponent } from '../shared/playlist-status-badge.component'; import { PlaylistThumbnailComponent } from '../shared/playlist-thumbnail.component'; import { SignageContextService } from '../signage-context.service'; import { playlistStatus } from '../signage-playlist.util'; @@ -102,57 +103,13 @@ import { playlistStatus } from '../signage-playlist.util'; class="flex flex-wrap gap-1 text-[0.625rem]" > @if (!playlist.enabled) { - - {{ - 'COMMON.DISABLED' - | translate - }} - - } - @switch (getStatus(playlist)) { - @case ('expired') { - - {{ - 'SIGNAGE_MANAGER.STATUS_EXPIRED' - | translate - }} - - } - @case ('pending') { - - {{ - 'COMMON.PENDING' - | translate - }} - - } - @case ('awaiting_review') { - - {{ - 'SIGNAGE_MANAGER.STATUS_AWAITING_REVIEW' - | translate - }} - - } - @case ('awaiting_approval') { - - {{ - 'COMMON.APPROVAL_REQUIRED' - | translate - }} - - } + } +
@if (playlist.description) {
-
-

- {{ 'SIGNAGE_MANAGER.PLAYLISTS_PAGE_TITLE' | translate }} -

-
-
- {{ - 'COMMON.ITEM_COUNT' - | translate - : { count: total_count() } - : total_count() - }} -
- -
-
-
- @if (can_create()) { - - } -
+ `, - imports: [ - MatRippleModule, - IconComponent, - TranslatePipe, - GroupBreadcrumbsComponent, - MatTooltipModule, - ], + imports: [SectionHeaderComponent], }) export class PlaylistHeaderComponent { private readonly _context = inject(SignageContextService); diff --git a/apps/signage-manager/src/app/playlists/playlist-list.component.ts b/apps/signage-manager/src/app/playlists/playlist-list.component.ts index dce562dd689..2d6998cc409 100644 --- a/apps/signage-manager/src/app/playlists/playlist-list.component.ts +++ b/apps/signage-manager/src/app/playlists/playlist-list.component.ts @@ -1,10 +1,4 @@ -import { - afterRenderEffect, - Component, - ElementRef, - inject, - viewChildren, -} from '@angular/core'; +import { Component, ElementRef, inject, viewChildren } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -18,7 +12,9 @@ import { } from '@placeos/components'; import { SignagePlaylist } from '@placeos/ts-client'; import { IntersectDirective } from '../shared/intersect.directive'; +import { PlaylistStatusBadgeComponent } from '../shared/playlist-status-badge.component'; import { PlaylistThumbnailComponent } from '../shared/playlist-thumbnail.component'; +import { scrollSelectionIntoView } from '../shared/scroll-selection.util'; import { playlistStatus } from '../signage-playlist.util'; import { SignagePlaylistService } from './signage-playlist.service'; @@ -95,52 +91,11 @@ import { SignagePlaylistService } from './signage-playlist.service'; class="flex flex-wrap gap-1 text-[0.625rem] font-medium uppercase" > @if (!playlist.enabled) { - - {{ 'COMMON.DISABLED' | translate }} - - } - @switch (getStatus(playlist)) { - @case ('expired') { - - {{ - 'SIGNAGE_MANAGER.STATUS_EXPIRED' - | translate - }} - - } - @case ('pending') { - - {{ 'COMMON.PENDING' | translate }} - - } - @case ('awaiting_review') { - - {{ - 'SIGNAGE_MANAGER.STATUS_AWAITING_REVIEW' - | translate - }} - - } - @case ('awaiting_approval') { - - {{ - 'COMMON.APPROVAL_REQUIRED' - | translate - }} - - } + } +
@if (playlist.description) {
{ - const selected_id = this.selected()?.id; - if (!selected_id) return; - const playlist_index = this.playlists().findIndex( - ({ id }) => id === selected_id, - ); - return this._playlist_items()[playlist_index]?.nativeElement; - }, - write: (selected_item) => { - selected_item()?.scrollIntoView?.({ - behavior: 'instant', - block: 'nearest', - inline: 'nearest', - }); - }, - }); + scrollSelectionIntoView( + this.playlists, + this._playlist_items, + this.selected, + ); } public loadMore() { diff --git a/apps/signage-manager/src/app/playlists/playlists.component.ts b/apps/signage-manager/src/app/playlists/playlists.component.ts index 94d2e27e057..a1c2a6e2e10 100644 --- a/apps/signage-manager/src/app/playlists/playlists.component.ts +++ b/apps/signage-manager/src/app/playlists/playlists.component.ts @@ -1,10 +1,15 @@ -import { Component, effect, inject, input, signal } from '@angular/core'; +import { Component, effect, inject, input } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Router } from '@angular/router'; import { i18n, notifyWarn } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { NavFooterComponent } from '../shared/nav-footer.component'; import { NavSidebarComponent } from '../shared/nav-sidebar.component'; +import { routedTab } from '../shared/routed-tab.util'; +import { + SectionTab, + SectionTabsComponent, +} from '../shared/section-tabs.component'; import { SignageContextService } from '../signage-context.service'; import { PlaylistActionsComponent } from './playlist-actions.component'; import { PlaylistHeaderComponent } from './playlist-header.component'; @@ -13,9 +18,9 @@ import { PlaylistItemsComponent } from './playlist-items.component'; import { PlaylistListComponent } from './playlist-list.component'; import { SignagePlaylistService } from './signage-playlist.service'; -const TAB_QUERY_PARAM = 'tab'; +type PlaylistTab = 'items' | 'details'; -function parsePlaylistTab(value: string | null): 'items' | 'details' { +function parsePlaylistTab(value: string | null): PlaylistTab { return value === 'details' ? 'details' : 'items'; } @@ -68,63 +73,17 @@ function parsePlaylistTab(value: string | null): 'items' | 'details' {
-
- - -
+ /> }
`, - styles: [ - ` - .tablet-hidden { - @media (max-width: 1023px) { - display: none !important; - } - } - - .tablet-full { - @media (max-width: 1023px) { - flex: 1; - min-width: 0; - } - } - - .mobile-hidden { - @media (max-width: 639px) { - display: none !important; - } - } - - .mobile-full { - @media (max-width: 639px) { - flex: 1; - } - } - `, - ], imports: [ NavSidebarComponent, NavFooterComponent, @@ -185,6 +116,7 @@ function parsePlaylistTab(value: string | null): 'items' | 'details' { PlaylistItemsComponent, PlaylistItemDetailsComponent, PlaylistActionsComponent, + SectionTabsComponent, MatRippleModule, IconComponent, TranslatePipe, @@ -193,13 +125,18 @@ function parsePlaylistTab(value: string | null): 'items' | 'details' { export class PlaylistsSectionComponent { private readonly _context = inject(SignageContextService); private readonly _playlist_service = inject(SignagePlaylistService); - private readonly _route = inject(ActivatedRoute); private readonly _router = inject(Router); public readonly id = input(''); public readonly tab = input(null); public readonly item = input(null); - public readonly view_tab = signal<'items' | 'details'>('items'); + private readonly _view_tab = routedTab(this.tab, parsePlaylistTab); + public readonly view_tab = this._view_tab.active; + /** Tabs shown below the lg breakpoint. Wider screens show both panels. */ + public readonly tabs: SectionTab[] = [ + { id: 'items', label: 'SIGNAGE_MANAGER.TAB_ITEMS' }, + { id: 'details', label: 'COMMON.DETAILS' }, + ]; public readonly selected_playlist = this._playlist_service.selected_playlist; @@ -212,13 +149,6 @@ export class PlaylistsSectionComponent { private _fetched_id = ''; constructor() { - effect(() => { - const route_tab = parsePlaylistTab(this.tab()); - if (route_tab !== this.view_tab()) { - this.view_tab.set(route_tab); - } - }); - // Sync selected playlist from route param effect(() => { const id = this.id(); @@ -301,14 +231,7 @@ export class PlaylistsSectionComponent { this._router.navigate(['/playlists'], {}); } - public setViewTab(tab: 'items' | 'details') { - if (tab === this.view_tab()) return; - this.view_tab.set(tab); - void this._router.navigate([], { - relativeTo: this._route, - queryParams: { [TAB_QUERY_PARAM]: tab }, - queryParamsHandling: 'merge', - replaceUrl: true, - }); + public setViewTab(tab: PlaylistTab) { + this._view_tab.select(tab); } } diff --git a/apps/signage-manager/src/app/schedules/schedules.component.ts b/apps/signage-manager/src/app/schedules/schedules.component.ts index 93704aa1e7a..4eedcb877e0 100644 --- a/apps/signage-manager/src/app/schedules/schedules.component.ts +++ b/apps/signage-manager/src/app/schedules/schedules.component.ts @@ -5,7 +5,6 @@ import { computed, inject, input, - linkedSignal, resource, signal, } from '@angular/core'; @@ -15,7 +14,6 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { ActivatedRoute, Router } from '@angular/router'; import { i18n } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { addDays, isSameDay, startOfDay } from 'date-fns'; @@ -23,6 +21,8 @@ import { SignagePlaylistService } from '../playlists/signage-playlist.service'; import { GroupBreadcrumbsComponent } from '../shared/group-breadcrumbs.component'; import { NavFooterComponent } from '../shared/nav-footer.component'; import { NavSidebarComponent } from '../shared/nav-sidebar.component'; +import { routedTab } from '../shared/routed-tab.util'; +import { tabKeyIndex } from '../shared/section-tabs.component'; import { SignageInventoryService } from '../signage-inventory.service'; import { ScheduleTimelineComponent } from './schedule-timeline.component'; import { @@ -32,9 +32,11 @@ import { buildZoneScheduleAssignments, } from './signage-schedule.util'; -const TAB_QUERY_PARAM = 'tab'; +type ScheduleTab = 'displays' | 'zones'; -function parseScheduleTab(value: string | null): 'displays' | 'zones' { +const SCHEDULE_TABS: readonly ScheduleTab[] = ['displays', 'zones']; + +function parseScheduleTab(value: string | null): ScheduleTab { return value === 'zones' ? 'zones' : 'displays'; } @@ -323,13 +325,11 @@ function filterRows(rows: ScheduleTimelineRow[], search_term: string) { export class SchedulesSectionComponent { private readonly _inventory_service = inject(SignageInventoryService); private readonly _playlist_service = inject(SignagePlaylistService); - private readonly _route = inject(ActivatedRoute); - private readonly _router = inject(Router); private readonly _destroy_ref = inject(DestroyRef); public readonly tab = input(null); - /** Follows the route, and changes at once when users pick a tab */ - public readonly view_tab = linkedSignal(() => parseScheduleTab(this.tab())); + private readonly _view_tab = routedTab(this.tab, parseScheduleTab); + public readonly view_tab = this._view_tab.active; public readonly search_term = signal(''); public readonly selected_date = signal(startOfDay(new Date())); public readonly current_time = signal(new Date()); @@ -505,28 +505,20 @@ export class SchedulesSectionComponent { this.search_term.set(''); } - public setViewTab(tab: 'displays' | 'zones') { - if (tab === this.view_tab()) return; - this.view_tab.set(tab); - void this._router.navigate([], { - relativeTo: this._route, - queryParams: { [TAB_QUERY_PARAM]: tab }, - queryParamsHandling: 'merge', - replaceUrl: true, - }); + public setViewTab(tab: ScheduleTab) { + this._view_tab.select(tab); } /** Move between the tabs with the arrow, Home and End keys */ public onTabKeydown(event: KeyboardEvent) { - const keys: Record = { - ArrowLeft: 'displays', - Home: 'displays', - ArrowRight: 'zones', - End: 'zones', - }; - const tab = keys[event.key]; - if (!tab) return; + const next = tabKeyIndex( + event.key, + SCHEDULE_TABS.indexOf(this.view_tab()), + SCHEDULE_TABS.length, + ); + if (next === null) return; event.preventDefault(); + const tab = SCHEDULE_TABS[next]; this.setViewTab(tab); document.getElementById(`schedules-tab-${tab}`)?.focus(); } diff --git a/apps/signage-manager/src/app/shared/assigned-playlists-panel.component.ts b/apps/signage-manager/src/app/shared/assigned-playlists-panel.component.ts new file mode 100644 index 00000000000..74d395d0330 --- /dev/null +++ b/apps/signage-manager/src/app/shared/assigned-playlists-panel.component.ts @@ -0,0 +1,173 @@ +import { Component, inject, input, output } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { RouterLink } from '@angular/router'; +import { + IconComponent, + LoadErrorComponent, + TranslatePipe, +} from '@placeos/components'; +import { SignagePlaylist } from '@placeos/ts-client'; +import { SignagePlaylistService } from '../playlists/signage-playlist.service'; +import { playlistStatus } from '../signage-playlist.util'; +import { PlaylistStatusBadgeComponent } from './playlist-status-badge.component'; +import { PlaylistThumbnailComponent } from './playlist-thumbnail.component'; + +/** + * Playlists assigned to a display or a zone, with their status, a link to + * each playlist, and add and remove buttons for users who can update. + */ +@Component({ + selector: 'assigned-playlists-panel', + template: ` +
+
+ playlist_play + {{ + 'SIGNAGE_MANAGER.PLAYLISTS_COUNT' + | translate + : { count: playlists().length } + : playlists().length + }} +
+ @if (can_update()) { + + } +
+
+ @if (playlists().length > 0) { + @for (playlist of playlists(); track playlist.id) { + + } + } @else if (loading()) { +
+ {{ 'COMMON.LOADING' | translate }} +
+ } @else if (error()) { + + } @else { +
+ playlist_remove +

+ {{ empty_label() | translate }} +

+
+ } +
+ `, + host: { class: 'block' }, + imports: [ + MatRippleModule, + MatTooltipModule, + RouterLink, + IconComponent, + LoadErrorComponent, + TranslatePipe, + PlaylistStatusBadgeComponent, + PlaylistThumbnailComponent, + ], +}) +export class AssignedPlaylistsPanelComponent { + private readonly _playlist_service = inject(SignagePlaylistService); + + public readonly playlists = input.required(); + /** Shows the loading state in place of the empty state */ + public readonly loading = input(false); + /** Shows a retry in place of the empty state */ + public readonly error = input(false); + public readonly can_update = input(false); + /** Translation key of the add button's accessible name */ + public readonly add_label = input.required(); + /** Translation key of each remove button's accessible name, given `name` */ + public readonly remove_label = input.required(); + /** Translation key of the text with no playlists */ + public readonly empty_label = input.required(); + + public readonly add = output(); + /** Emits the id of the playlist to remove */ + public readonly remove = output(); + public readonly retry = output(); + + public getStatus(playlist: SignagePlaylist) { + return playlistStatus( + playlist, + this._playlist_service.playlist_approval_status(), + this._playlist_service.playlist_approval_requested_status(), + ); + } +} diff --git a/apps/signage-manager/src/app/shared/display-select-modal.component.ts b/apps/signage-manager/src/app/shared/display-select-modal.component.ts index ab4eb5d14f8..82d2d7b3708 100644 --- a/apps/signage-manager/src/app/shared/display-select-modal.component.ts +++ b/apps/signage-manager/src/app/shared/display-select-modal.component.ts @@ -1,125 +1,56 @@ import { Component, inject } from '@angular/core'; -import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatDialogModule } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { - IconComponent, - LoadErrorComponent, - TranslatePipe, -} from '@placeos/components'; +import { IconComponent } from '@placeos/components'; import { PlaceSystem } from '@placeos/ts-client'; import { SignageDisplayService } from '../displays/signage-display.service'; -import { IntersectDirective } from './intersect.directive'; import { byDisplayName, PagedSearch } from './paged-search'; +import { + PickerItemDirective, + PickerModalComponent, +} from './picker-modal.component'; @Component({ selector: 'display-select-modal', template: ` -
-

- {{ 'SIGNAGE_MANAGER.ADD_DISPLAY_TITLE' | translate }} -

- -
-
- - - - @if (list.items().length > 0) { - @for (display of list.items(); track display.id) { - - } - @if (list.has_more()) { -
- } @else if (list.error()) { - - } - } @else if (list.loading()) { -
-
- {{ 'COMMON.LOADING' | translate }} -
-
- } @else if (list.error()) { - - } @else { -
- tv -
- {{ 'SIGNAGE_MANAGER.NO_DISPLAYS' | translate }} + @if (display.description) { +
+ {{ display.description }} +
+ }
-
- } -
+ + + `, imports: [ - FormsModule, MatRippleModule, MatDialogModule, - MatFormFieldModule, - MatInputModule, IconComponent, - LoadErrorComponent, - TranslatePipe, - IntersectDirective, + PickerModalComponent, + PickerItemDirective, ], }) export class DisplaySelectModalComponent { diff --git a/apps/signage-manager/src/app/shared/linked-item-row.component.ts b/apps/signage-manager/src/app/shared/linked-item-row.component.ts new file mode 100644 index 00000000000..73a9b8f6a1c --- /dev/null +++ b/apps/signage-manager/src/app/shared/linked-item-row.component.ts @@ -0,0 +1,42 @@ +import { Component, input } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { RouterLink } from '@angular/router'; +import { IconComponent, TranslatePipe } from '@placeos/components'; + +/** + * Row that links to a related display or zone, with an icon, the name and + * the description. + */ +@Component({ + selector: 'linked-item-row', + template: ` + + {{ icon() }} +
+
{{ name() }}
+ @if (description()) { +
+ {{ description() }} +
+ } +
+
+ `, + host: { class: 'block' }, + imports: [MatRippleModule, RouterLink, IconComponent, TranslatePipe], +}) +export class LinkedItemRowComponent { + /** Router link of the item */ + public readonly link = input.required(); + /** Material icon name */ + public readonly icon = input.required(); + public readonly name = input.required(); + public readonly description = input(''); + /** Translation key of the link's accessible name, given `name` */ + public readonly open_label = input.required(); +} diff --git a/apps/signage-manager/src/app/shared/media-select-modal.component.ts b/apps/signage-manager/src/app/shared/media-select-modal.component.ts index 7d68ebb1b14..420af99b380 100644 --- a/apps/signage-manager/src/app/shared/media-select-modal.component.ts +++ b/apps/signage-manager/src/app/shared/media-select-modal.component.ts @@ -1,19 +1,15 @@ import { Component, inject } from '@angular/core'; -import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { - IconComponent, - LoadErrorComponent, - TranslatePipe, -} from '@placeos/components'; +import { IconComponent } from '@placeos/components'; import { SignageMedia } from '@placeos/ts-client'; import { SignageMediaService } from '../media/signage-media.service'; -import { IntersectDirective } from './intersect.directive'; import { MediaThumbnailComponent } from './media-thumbnail.component'; import { byName, PagedSearch } from './paged-search'; +import { + PickerItemDirective, + PickerModalComponent, +} from './picker-modal.component'; export interface MediaSelectModalData { selected_id: string; @@ -22,111 +18,51 @@ export interface MediaSelectModalData { @Component({ selector: 'media-select-modal', template: ` -
-

- {{ 'SIGNAGE_MANAGER.TEMPLATE_BACKGROUND_SELECT' | translate }} -

- -
-
- - - - @if (list.items().length > 0) { - @for (media_item of list.items(); track media_item.id) { - - } - @if (list.has_more()) { -
- } @else if (list.error()) { - - } - } @else if (list.loading()) { -
+
- } @else if (list.error()) { - - } @else { -
- image -
- {{ 'SIGNAGE_MANAGER.NO_MEDIA' | translate }} + +
+
{{ media_item.name }}
+
+ {{ media_item.media_type }} +
-
- } -
+ @if (media_item.id === selected_id) { + check + } + + + `, imports: [ - FormsModule, MatRippleModule, MatDialogModule, - MatFormFieldModule, - MatInputModule, IconComponent, - LoadErrorComponent, - TranslatePipe, - IntersectDirective, MediaThumbnailComponent, + PickerModalComponent, + PickerItemDirective, ], }) export class MediaSelectModalComponent { diff --git a/apps/signage-manager/src/app/shared/picker-modal.component.ts b/apps/signage-manager/src/app/shared/picker-modal.component.ts new file mode 100644 index 00000000000..80c1218201c --- /dev/null +++ b/apps/signage-manager/src/app/shared/picker-modal.component.ts @@ -0,0 +1,153 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { + Component, + contentChild, + Directive, + inject, + input, + TemplateRef, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { + IconComponent, + LoadErrorComponent, + TranslatePipe, +} from '@placeos/components'; +import { IntersectDirective } from './intersect.directive'; +import { PagedSearch } from './paged-search'; + +/** + * Marks the item template of a ``. Bind the list to type the + * item: ``. + */ +@Directive({ selector: 'ng-template[pickerItem]' }) +export class PickerItemDirective { + /** List of the items, which sets the item type */ + public readonly pickerItem = input.required>(); + public readonly template = + inject>(TemplateRef); + + public static ngTemplateContextGuard( + _directive: PickerItemDirective, + context: unknown, + ): context is { $implicit: T } { + return true; + } +} + +/** + * Frame of a dialog that picks one item from a searchable, paged list: the + * header with a close button, the search field, the list with its loading, + * error and empty states, and the sentinel that loads the next page. + * + * Give the item button as an `` + * child. It usually closes the dialog with `[mat-dialog-close]`. + */ +@Component({ + selector: 'picker-modal', + template: ` +
+

+ {{ heading() | translate }} +

+ +
+
+ + + + @if (list().items().length > 0) { + @for (entry of list().items(); track entry.id) { + + } + @if (list().has_more()) { +
+ } @else if (list().error()) { + + } + } @else if (list().loading()) { +
+
+ {{ 'COMMON.LOADING' | translate }} +
+
+ } @else if (list().error()) { + + } @else { +
+ {{ + empty_icon() + }} +
+ {{ empty_label() | translate }} +
+
+ } +
+ `, + // Lays the header and the main out as children of the dialog component + host: { class: 'contents' }, + imports: [ + NgTemplateOutlet, + FormsModule, + MatRippleModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + IconComponent, + LoadErrorComponent, + TranslatePipe, + IntersectDirective, + ], +}) +export class PickerModalComponent { + public readonly list = input.required>(); + /** Translation key of the dialog title */ + public readonly heading = input.required(); + /** Translation key of the close button's accessible name */ + public readonly close_label = input.required(); + /** Translation key of the search field's placeholder and name */ + public readonly search_label = input.required(); + /** Material icon of the empty state */ + public readonly empty_icon = input.required(); + /** Translation key of the empty state */ + public readonly empty_label = input.required(); + + public readonly item_template = + contentChild.required>(PickerItemDirective); +} diff --git a/apps/signage-manager/src/app/shared/playlist-select-modal.component.ts b/apps/signage-manager/src/app/shared/playlist-select-modal.component.ts index 55c214df61a..b0a97210f66 100644 --- a/apps/signage-manager/src/app/shared/playlist-select-modal.component.ts +++ b/apps/signage-manager/src/app/shared/playlist-select-modal.component.ts @@ -1,126 +1,54 @@ import { Component, inject } from '@angular/core'; -import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatDialogModule } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { - IconComponent, - LoadErrorComponent, - TranslatePipe, -} from '@placeos/components'; +import { IconComponent } from '@placeos/components'; import { SignagePlaylist } from '@placeos/ts-client'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; -import { IntersectDirective } from './intersect.directive'; import { byName, PagedSearch } from './paged-search'; +import { + PickerItemDirective, + PickerModalComponent, +} from './picker-modal.component'; @Component({ selector: 'playlist-select-modal', template: ` -
-

- {{ 'SIGNAGE_MANAGER.ADD_TO_PLAYLIST' | translate }} -

- -
-
- - - - @if (list.items().length > 0) { - @for (playlist of list.items(); track playlist.id) { - - } - @if (list.has_more()) { -
- } @else if (list.error()) { - - } - } @else if (list.loading()) { -
-
- {{ 'COMMON.LOADING' | translate }} -
-
- } @else if (list.error()) { - - } @else { -
+
+ +
+
`, imports: [ - FormsModule, MatRippleModule, MatDialogModule, - MatFormFieldModule, - MatInputModule, IconComponent, - LoadErrorComponent, - TranslatePipe, - IntersectDirective, + PickerModalComponent, + PickerItemDirective, ], }) export class PlaylistSelectModalComponent { diff --git a/apps/signage-manager/src/app/shared/playlist-status-badge.component.ts b/apps/signage-manager/src/app/shared/playlist-status-badge.component.ts new file mode 100644 index 00000000000..609d58a74c5 --- /dev/null +++ b/apps/signage-manager/src/app/shared/playlist-status-badge.component.ts @@ -0,0 +1,56 @@ +import { Component, input } from '@angular/core'; +import { TranslatePipe } from '@placeos/components'; +import { PlaylistStatus } from '../signage-playlist.util'; + +/** State that a `` shows */ +export type PlaylistBadgeStatus = 'disabled' | PlaylistStatus; + +const BADGES: Record< + NonNullable, + { label: string; colour: string } +> = { + disabled: { + label: 'COMMON.DISABLED', + colour: 'bg-warning text-warning-content', + }, + expired: { + label: 'SIGNAGE_MANAGER.STATUS_EXPIRED', + colour: 'bg-error text-error-content', + }, + pending: { label: 'COMMON.PENDING', colour: 'bg-info text-info-content' }, + awaiting_review: { + label: 'SIGNAGE_MANAGER.STATUS_AWAITING_REVIEW', + colour: 'bg-warning text-warning-content', + }, + awaiting_approval: { + label: 'COMMON.APPROVAL_REQUIRED', + colour: 'bg-base-300', + }, +}; + +/** + * Small label for the state of a playlist or template, such as expired or + * awaiting review. Shows nothing for a null status. + */ +@Component({ + selector: 'playlist-status-badge', + imports: [TranslatePipe], + template: ` + @let status_value = status(); + @if (status_value) { + + {{ badges[status_value].label | translate }} + + } + `, + host: { class: 'contents' }, +}) +export class PlaylistStatusBadgeComponent { + public readonly status = input.required(); + + public readonly badges = BADGES; +} diff --git a/apps/signage-manager/src/app/shared/routed-tab.util.ts b/apps/signage-manager/src/app/shared/routed-tab.util.ts new file mode 100644 index 00000000000..f60774024d3 --- /dev/null +++ b/apps/signage-manager/src/app/shared/routed-tab.util.ts @@ -0,0 +1,35 @@ +import { inject, linkedSignal, type Signal } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +/** + * Tab of a page that the `tab` query param holds. The tab follows the route, + * and changes at once when users pick a tab. Picking a tab writes it to the + * URL in place of the current history entry. + * + * Injects the router, so call it in an injection context. + * @param param Value of the `tab` query param, such as a route-bound input + * @param parse Turns the query value into a tab the page can show + */ +export function routedTab( + param: Signal, + parse: (value: string | null) => T, +) { + const route = inject(ActivatedRoute); + const router = inject(Router); + const active = linkedSignal(() => parse(param())); + + return { + active: active.asReadonly(), + /** Show `tab` and write it to the URL */ + select(tab: T) { + if (tab === active()) return; + active.set(tab); + void router.navigate([], { + relativeTo: route, + queryParams: { tab }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + }, + }; +} diff --git a/apps/signage-manager/src/app/shared/scroll-selection.util.ts b/apps/signage-manager/src/app/shared/scroll-selection.util.ts new file mode 100644 index 00000000000..b42320daa91 --- /dev/null +++ b/apps/signage-manager/src/app/shared/scroll-selection.util.ts @@ -0,0 +1,32 @@ +import { afterRenderEffect, type ElementRef } from '@angular/core'; + +/** + * Keep the row of the selected item in view, such as after a route opens an + * item further down a list. Rows must be in the same order as the items. + * + * Creates an after-render effect, so call it in an injection context. + * @param items Items of the list + * @param rows Row element of each item, such as from `viewChildren` + * @param selected Item to show + */ +export function scrollSelectionIntoView( + items: () => readonly T[], + rows: () => readonly ElementRef[], + selected: () => T | null | undefined, +) { + afterRenderEffect({ + earlyRead: () => { + const selected_id = selected()?.id; + if (!selected_id) return; + const index = items().findIndex(({ id }) => id === selected_id); + return rows()[index]?.nativeElement; + }, + write: (row) => { + row()?.scrollIntoView?.({ + behavior: 'instant', + block: 'nearest', + inline: 'nearest', + }); + }, + }); +} diff --git a/apps/signage-manager/src/app/shared/section-header.component.ts b/apps/signage-manager/src/app/shared/section-header.component.ts new file mode 100644 index 00000000000..a2ed8253444 --- /dev/null +++ b/apps/signage-manager/src/app/shared/section-header.component.ts @@ -0,0 +1,71 @@ +import { Component, input, output } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { GroupBreadcrumbsComponent } from './group-breadcrumbs.component'; + +/** + * Sticky header of a list page. Shows the page title, the item count, the + * group breadcrumbs and an icon button that emits `add`. + */ +@Component({ + selector: 'section-header', + template: ` +
+
+

+ {{ heading() | translate }} +

+
+ @let total = count(); + @if (total !== null) { +
+ {{ + 'COMMON.ITEM_COUNT' + | translate: { count: total } : total + }} +
+ } + +
+
+
+ @if (can_add()) { + + } +
+ `, + imports: [ + MatRippleModule, + IconComponent, + TranslatePipe, + GroupBreadcrumbsComponent, + MatTooltipModule, + ], +}) +export class SectionHeaderComponent { + /** Translation key of the page title. Not `title`, which would also set the native tooltip. */ + public readonly heading = input.required(); + /** Number of items, or null to hide the count while it is not known */ + public readonly count = input.required(); + public readonly can_add = input(false); + /** Translation key of the add button's accessible name */ + public readonly add_label = input.required(); + /** Translation key of the add button's tooltip */ + public readonly add_tooltip = input.required(); + + public readonly add = output(); +} diff --git a/apps/signage-manager/src/app/shared/section-tabs.component.ts b/apps/signage-manager/src/app/shared/section-tabs.component.ts new file mode 100644 index 00000000000..8ff77d68c82 --- /dev/null +++ b/apps/signage-manager/src/app/shared/section-tabs.component.ts @@ -0,0 +1,95 @@ +import { Component, ElementRef, inject, input, model } from '@angular/core'; +import { TranslatePipe } from '@placeos/components'; + +/** One tab of a `` bar */ +export interface SectionTab { + readonly id: T; + /** Translation key of the tab name */ + readonly label: string; + /** Number in a badge after the name. Leave it out for no badge. */ + readonly count?: number; + /** Shows "?" in the badge and marks it busy while the count loads */ + readonly count_loading?: boolean; +} + +/** + * Index of the tab that a key moves to in a tab list of `count` tabs. + * Home and End go to the ends, the arrow keys wrap around. + * @returns The new index, or null when the key does not move between tabs + */ +export function tabKeyIndex(key: string, index: number, count: number) { + if (key === 'Home') return 0; + if (key === 'End') return count - 1; + if (key === 'ArrowLeft') return (index - 1 + count) % count; + if (key === 'ArrowRight') return (index + 1) % count; + return null; +} + +/** + * Tab bar of a section. Only the active tab is in the tab order. The arrow + * keys, Home and End move between the tabs. + * + * Each tab gets the id `{id_prefix}-{id}-tab` and controls the panel + * `{id_prefix}-{id}-panel`. Set the `aria-label` and the layout classes, such as + * `flex`, on the host element, which is the tab list. + */ +@Component({ + selector: 'section-tabs', + imports: [TranslatePipe], + host: { + role: 'tablist', + '(keydown)': 'onKeydown($event)', + }, + template: ` + @for (tab of tabs(); track tab.id) { + + } + `, +}) +export class SectionTabsComponent { + private readonly _element = inject>(ElementRef); + + public readonly tabs = input.required[]>(); + public readonly active = model.required(); + /** Start of the tab and panel ids */ + public readonly id_prefix = input.required(); + + /** Move to and focus the tab that an arrow, Home or End key points to */ + public onKeydown(event: KeyboardEvent) { + const tabs = this.tabs(); + const next = tabKeyIndex( + event.key, + tabs.findIndex(({ id }) => id === this.active()), + tabs.length, + ); + if (next === null) return; + event.preventDefault(); + this.active.set(tabs[next].id); + this._element.nativeElement + .querySelectorAll('[role="tab"]') + [next]?.focus(); + } +} diff --git a/apps/signage-manager/src/app/templates/template-header.component.ts b/apps/signage-manager/src/app/templates/template-header.component.ts index d593ee2b3d0..3399f55ffe3 100644 --- a/apps/signage-manager/src/app/templates/template-header.component.ts +++ b/apps/signage-manager/src/app/templates/template-header.component.ts @@ -1,59 +1,21 @@ import { Component, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { GroupBreadcrumbsComponent } from '../shared/group-breadcrumbs.component'; +import { SectionHeaderComponent } from '../shared/section-header.component'; import { SignageContextService } from '../signage-context.service'; import { SignageTemplateService } from './signage-template.service'; @Component({ selector: 'template-header', template: ` -
-
-

- {{ 'SIGNAGE_MANAGER.TEMPLATES_PAGE_TITLE' | translate }} -

-
-
- {{ - 'COMMON.ITEM_COUNT' - | translate - : { count: total_count() } - : total_count() - }} -
- -
-
-
- @if (can_create()) { - - } -
+ `, - imports: [ - MatRippleModule, - IconComponent, - TranslatePipe, - GroupBreadcrumbsComponent, - MatTooltipModule, - ], + imports: [SectionHeaderComponent], }) export class TemplateHeaderComponent { private readonly _context = inject(SignageContextService); diff --git a/apps/signage-manager/src/app/templates/template-layout-list.component.ts b/apps/signage-manager/src/app/templates/template-layout-list.component.ts index d458f87eacc..8bad4ec4336 100644 --- a/apps/signage-manager/src/app/templates/template-layout-list.component.ts +++ b/apps/signage-manager/src/app/templates/template-layout-list.component.ts @@ -38,6 +38,10 @@ import { SignageTemplateMapping, } from '@placeos/ts-client'; import { SignageDisplayService } from '../displays/signage-display.service'; +import { + SectionTab, + SectionTabsComponent, +} from '../shared/section-tabs.component'; import { SignageContextService } from '../signage-context.service'; import { playlistScheduleLabel } from '../signage-playlist.util'; import { SignagePluginService } from '../signage-plugin.service'; @@ -56,7 +60,6 @@ import { layoutPositionIcon, layoutPositionLabel, layoutPositionValid, - tabKeyIndex, } from './template-layout.util'; @Component({ @@ -65,50 +68,15 @@ import {
- + /> @if (view_tab() === 'items') {
('items', { alias: 'tab', }); + public readonly tabs: SectionTab<'items' | 'details'>[] = [ + { id: 'items', label: 'SIGNAGE_MANAGER.TEMPLATE_LAYOUT_ITEMS' }, + { id: 'details', label: 'COMMON.DETAILS' }, + ]; public readonly positions = LAYOUT_POSITIONS; public readonly layouts = this._template_service.template_layout_draft; public readonly selected_template = @@ -852,23 +825,6 @@ export class TemplateLayoutListComponent { this.view_tab.set(tab); } - public handleTabKeydown( - event: KeyboardEvent, - items_tab: HTMLButtonElement, - details_tab: HTMLButtonElement, - ) { - const tabs = ['items', 'details'] as const; - const next = tabKeyIndex( - event.key, - tabs.indexOf(this.view_tab()), - tabs.length, - ); - if (next === null) return; - event.preventDefault(); - this.view_tab.set(tabs[next]); - [items_tab, details_tab][next].focus(); - } - public mappingTargetLabel(mapping: SignageTemplateMapping) { if (mapping.control_system_id) { const display = this.displays().find( diff --git a/apps/signage-manager/src/app/templates/template-layout.util.ts b/apps/signage-manager/src/app/templates/template-layout.util.ts index 89c66eb8d0a..1072e30a0a2 100644 --- a/apps/signage-manager/src/app/templates/template-layout.util.ts +++ b/apps/signage-manager/src/app/templates/template-layout.util.ts @@ -84,19 +84,6 @@ export function layoutPositionAxes(layout: SignageTemplateLayout) { return axes; } -/** - * Index of the tab that a key moves to in a tab list of `count` tabs. - * Home and End go to the ends, the arrow keys wrap around. - * @returns The new index, or null when the key does not move between tabs - */ -export function tabKeyIndex(key: string, index: number, count: number) { - if (key === 'Home') return 0; - if (key === 'End') return count - 1; - if (key === 'ArrowLeft') return (index - 1 + count) % count; - if (key === 'ArrowRight') return (index + 1) % count; - return null; -} - /** Add the displayed position defaults before sending a layout to the API. */ export function applyLayoutPositionDefaults( layout: SignageTemplateLayout, diff --git a/apps/signage-manager/src/app/templates/template-list.component.ts b/apps/signage-manager/src/app/templates/template-list.component.ts index f4667a7faf6..18bb5873492 100644 --- a/apps/signage-manager/src/app/templates/template-list.component.ts +++ b/apps/signage-manager/src/app/templates/template-list.component.ts @@ -1,10 +1,4 @@ -import { - afterRenderEffect, - Component, - ElementRef, - inject, - viewChildren, -} from '@angular/core'; +import { Component, ElementRef, inject, viewChildren } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -18,6 +12,8 @@ import { } from '@placeos/components'; import { SignageTemplate } from '@placeos/ts-client'; import { IntersectDirective } from '../shared/intersect.directive'; +import { PlaylistStatusBadgeComponent } from '../shared/playlist-status-badge.component'; +import { scrollSelectionIntoView } from '../shared/scroll-selection.util'; import { SignageTemplateService } from './signage-template.service'; type TemplateStatus = 'awaiting_approval' | 'awaiting_review' | null; @@ -89,28 +85,9 @@ type TemplateStatus = 'awaiting_approval' | 'awaiting_review' | null; } }} - @switch (getStatus(template)) { - @case ('awaiting_review') { - - {{ - 'SIGNAGE_MANAGER.STATUS_AWAITING_REVIEW' - | translate - }} - - } - @case ('awaiting_approval') { - - {{ - 'COMMON.APPROVAL_REQUIRED' - | translate - }} - - } - } +
@if (template.description) {
{ - const selected_id = this.selected()?.id; - if (!selected_id) return; - const template_index = this.templates().findIndex( - ({ id }) => id === selected_id, - ); - return this._template_items()[template_index]?.nativeElement; - }, - write: (selected_item) => { - selected_item()?.scrollIntoView?.({ - behavior: 'instant', - block: 'nearest', - inline: 'nearest', - }); - }, - }); + scrollSelectionIntoView( + this.templates, + this._template_items, + this.selected, + ); } public loadMore() { diff --git a/apps/signage-manager/src/app/templates/templates.component.ts b/apps/signage-manager/src/app/templates/templates.component.ts index 80cdc3b28d2..de4de95633f 100644 --- a/apps/signage-manager/src/app/templates/templates.component.ts +++ b/apps/signage-manager/src/app/templates/templates.component.ts @@ -13,6 +13,10 @@ import { Router } from '@angular/router'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { NavFooterComponent } from '../shared/nav-footer.component'; import { NavSidebarComponent } from '../shared/nav-sidebar.component'; +import { + SectionTab, + SectionTabsComponent, +} from '../shared/section-tabs.component'; import { SignageContextService } from '../signage-context.service'; import { SignageTemplateService, @@ -20,7 +24,6 @@ import { } from './signage-template.service'; import { TemplateHeaderComponent } from './template-header.component'; import { TemplateLayoutListComponent } from './template-layout-list.component'; -import { tabKeyIndex } from './template-layout.util'; import { TemplateListComponent } from './template-list.component'; import { TemplatePreviewComponent } from './template-preview.component'; @@ -199,45 +202,13 @@ type TemplateViewTab = 'preview' | 'layouts' | 'details'; }
-
- @for (tab of tabs; track tab.id) { - - } -
+ />
@@ -293,34 +264,6 @@ type TemplateViewTab = 'preview' | 'layouts' | 'details';
`, host: { '(window:beforeunload)': 'onBeforeUnload($event)' }, - styles: [ - ` - .mobile-hidden { - @media (max-width: 639px) { - display: none !important; - } - } - - .mobile-full { - @media (max-width: 639px) { - flex: 1; - } - } - - .tablet-hidden { - @media (max-width: 1023px) { - display: none !important; - } - } - - .tablet-full { - @media (max-width: 1023px) { - flex: 1; - min-width: 0; - } - } - `, - ], imports: [ NavSidebarComponent, NavFooterComponent, @@ -328,6 +271,7 @@ type TemplateViewTab = 'preview' | 'layouts' | 'details'; TemplateListComponent, TemplatePreviewComponent, TemplateLayoutListComponent, + SectionTabsComponent, MatRippleModule, MatProgressSpinnerModule, MatTooltipModule, @@ -342,7 +286,7 @@ export class TemplatesSectionComponent { public readonly id = input(''); /** Tabs shown below the lg breakpoint. Wider screens show all panels. */ - public readonly tabs: { id: TemplateViewTab; label: string }[] = [ + public readonly tabs: SectionTab[] = [ { id: 'preview', label: 'COMMON.PREVIEW' }, { id: 'layouts', label: 'SIGNAGE_MANAGER.TEMPLATE_LAYOUT_ITEMS' }, { id: 'details', label: 'COMMON.DETAILS' }, @@ -505,21 +449,6 @@ export class TemplatesSectionComponent { this.view_tab.set(tab === 'details' ? 'details' : 'layouts'); } - public handleTabKeydown(event: KeyboardEvent) { - const ids = this.tabs.map(({ id }) => id); - const next = tabKeyIndex( - event.key, - ids.indexOf(this.view_tab()), - ids.length, - ); - if (next === null) return; - event.preventDefault(); - this.view_tab.set(ids[next]); - (event.currentTarget as HTMLElement | null)?.parentElement - ?.querySelectorAll('[role="tab"]') - [next]?.focus(); - } - /** * Fetch the template of the route and select it. The service lists it * unless a search is active, so it is selected here directly. diff --git a/apps/signage-manager/src/app/zones/signage-zone.service.ts b/apps/signage-manager/src/app/zones/signage-zone.service.ts index 9cae3390d5d..933ff2fcac4 100644 --- a/apps/signage-manager/src/app/zones/signage-zone.service.ts +++ b/apps/signage-manager/src/app/zones/signage-zone.service.ts @@ -319,7 +319,6 @@ export class SignageZoneService { const { PlaylistSelectModalComponent } = await import('../shared/playlist-select-modal.component'); const ref = this._dialog.open(PlaylistSelectModalComponent, { - data: { zone_id: zone.id }, panelClass: 'mobile-fullscreen', }); const playlist_id = await dialogClosed(ref); @@ -346,7 +345,6 @@ export class SignageZoneService { const { ZoneSelectModalComponent } = await import('../shared/zone-select-modal.component'); const ref = this._dialog.open(ZoneSelectModalComponent, { - data: { playlist_id: playlist.id }, panelClass: 'mobile-fullscreen', }); const zone_id = await dialogClosed(ref); diff --git a/apps/signage-manager/src/app/zones/zone-content.component.ts b/apps/signage-manager/src/app/zones/zone-content.component.ts index 8b4932919c3..fa5109d7fe8 100644 --- a/apps/signage-manager/src/app/zones/zone-content.component.ts +++ b/apps/signage-manager/src/app/zones/zone-content.component.ts @@ -1,19 +1,17 @@ import { Component, computed, inject, input } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { RouterLink } from '@angular/router'; import { IconComponent, LoadErrorComponent, TranslatePipe, } from '@placeos/components'; -import { SignagePlaylist } from '@placeos/ts-client'; import { SignageDisplayService } from '../displays/signage-display.service'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; -import { PlaylistThumbnailComponent } from '../shared/playlist-thumbnail.component'; +import { AssignedPlaylistsPanelComponent } from '../shared/assigned-playlists-panel.component'; +import { LinkedItemRowComponent } from '../shared/linked-item-row.component'; import { TemplateMappingsComponent } from '../shared/template-mappings.component'; import { SignageContextService } from '../signage-context.service'; -import { playlistStatus } from '../signage-playlist.util'; import { SignageZoneService } from './signage-zone.service'; @Component({ @@ -29,204 +27,22 @@ import { SignageZoneService } from './signage-zone.service'; class="bg-base-100 border-base-300 min-h-0 flex-1 overflow-auto rounded-lg border" [class.hidden]="activeTab() !== 'playlists'" > -
-
- playlist_play - {{ - 'SIGNAGE_MANAGER.PLAYLISTS_COUNT' - | translate - : { count: zone_playlists().length } - : zone_playlists().length - }} -
- @if (can_update()) { - - } -
- - } @else if ( + " + [error]=" has_assigned_playlists() && playlists_error() - ) { - - } @else { -
- playlist_remove -

- {{ - 'SIGNAGE_MANAGER.NO_PLAYLISTS_ZONE' - | translate - }} -

-
- } -
+ " + [can_update]="can_update()" + (add)="addPlaylist()" + (remove)="removePlaylist($event)" + (retry)="reloadPlaylists()" + />
- tv -
-
- {{ - display.display_name || - display.name - }} -
- @if (display.description) { -
- {{ display.description }} -
- } -
- + [description]="display.description" + /> } } @else if (zone_displays_loading()) {
@@ -428,9 +215,7 @@ export class ZoneContentComponent { if (zone) this._zone_service.addPlaylistToZone(zone); } - public removePlaylist(event: Event, playlist_id: string) { - event.preventDefault(); - event.stopPropagation(); + public removePlaylist(playlist_id: string) { const zone = this.selected_zone(); if (zone) this._zone_service.removePlaylistFromZone(zone, playlist_id); } @@ -439,12 +224,4 @@ export class ZoneContentComponent { const zone = this.selected_zone(); if (zone) this._display_service.addDisplayToZone(zone); } - - public getStatus(playlist: SignagePlaylist) { - return playlistStatus( - playlist, - this.playlist_approval_status(), - this._playlist_service.playlist_approval_requested_status(), - ); - } } diff --git a/apps/signage-manager/src/app/zones/zone-header.component.ts b/apps/signage-manager/src/app/zones/zone-header.component.ts index f0ef35747bd..51340bbef3f 100644 --- a/apps/signage-manager/src/app/zones/zone-header.component.ts +++ b/apps/signage-manager/src/app/zones/zone-header.component.ts @@ -1,61 +1,22 @@ import { Component, computed, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { MatTooltipModule } from '@angular/material/tooltip'; import { Router } from '@angular/router'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { GroupBreadcrumbsComponent } from '../shared/group-breadcrumbs.component'; +import { SectionHeaderComponent } from '../shared/section-header.component'; import { SignageContextService } from '../signage-context.service'; import { SignageZoneService } from './signage-zone.service'; @Component({ selector: 'zone-header', template: ` -
-
-

- {{ 'SIGNAGE_MANAGER.ZONES_TITLE' | translate }} -

-
- @let count = total_count(); - @if (count !== null) { -
- {{ - 'COMMON.ITEM_COUNT' - | translate: { count } : count - }} -
- } - -
-
-
- @if (can_manage_zones()) { - - } -
+ `, - imports: [ - MatRippleModule, - IconComponent, - TranslatePipe, - GroupBreadcrumbsComponent, - MatTooltipModule, - ], + imports: [SectionHeaderComponent], }) export class ZoneHeaderComponent { private readonly _context = inject(SignageContextService); diff --git a/apps/signage-manager/src/app/zones/zones.component.ts b/apps/signage-manager/src/app/zones/zones.component.ts index e31028ce6a1..6c175c3317f 100644 --- a/apps/signage-manager/src/app/zones/zones.component.ts +++ b/apps/signage-manager/src/app/zones/zones.component.ts @@ -1,15 +1,7 @@ -import { - Component, - computed, - effect, - inject, - input, - resource, - signal, -} from '@angular/core'; +import { Component, computed, inject, input, resource } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Router } from '@angular/router'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { showZone } from '@placeos/ts-client'; import { selectRoutedItem } from '../displays/routed-selection.util'; @@ -18,6 +10,11 @@ import { SignagePlaylistService } from '../playlists/signage-playlist.service'; import { decodeEntityNames } from '../shared/decode-entity-names.util'; import { NavFooterComponent } from '../shared/nav-footer.component'; import { NavSidebarComponent } from '../shared/nav-sidebar.component'; +import { routedTab } from '../shared/routed-tab.util'; +import { + SectionTab, + SectionTabsComponent, +} from '../shared/section-tabs.component'; import { SignageContextService } from '../signage-context.service'; import { SignageTemplateService } from '../templates/signage-template.service'; import { SignageZoneService } from './signage-zone.service'; @@ -25,11 +22,9 @@ import { ZoneContentComponent } from './zone-content.component'; import { ZoneHeaderComponent } from './zone-header.component'; import { ZoneListComponent } from './zone-list.component'; -const TAB_QUERY_PARAM = 'tab'; +type ZoneTab = 'playlists' | 'displays' | 'templates'; -function parseZoneTab( - value: string | null, -): 'playlists' | 'displays' | 'templates' { +function parseZoneTab(value: string | null): ZoneTab { if (value === 'displays' || value === 'templates') return value; return 'playlists'; } @@ -123,137 +118,17 @@ function parseZoneTab( }
-
- - - @if (templates_enabled()) { - - } -
+ /> }
`, - styles: [ - ` - .mobile-hidden { - @media (max-width: 639px) { - display: none !important; - } - } - - .mobile-full { - @media (max-width: 639px) { - flex: 1; - } - } - `, - ], imports: [ NavSidebarComponent, NavFooterComponent, ZoneHeaderComponent, ZoneListComponent, ZoneContentComponent, + SectionTabsComponent, MatRippleModule, MatTooltipModule, IconComponent, @@ -298,15 +159,18 @@ export class ZonesSectionComponent { private readonly _playlist_service = inject(SignagePlaylistService); private readonly _template_service = inject(SignageTemplateService); private readonly _zone_service = inject(SignageZoneService); - private readonly _route = inject(ActivatedRoute); private readonly _router = inject(Router); public readonly id = input(''); public readonly tab = input(null); public readonly templates_enabled = this._context.templates_enabled; - public readonly view_tab = signal<'playlists' | 'displays' | 'templates'>( - 'playlists', - ); + private readonly _view_tab = routedTab(this.tab, (value) => { + const tab = parseZoneTab(value); + return tab === 'templates' && !this.templates_enabled() + ? 'playlists' + : tab; + }); + public readonly view_tab = this._view_tab.active; public readonly selected_zone = this._zone_service.selected_zone; public readonly can_manage_selected_zone = computed(() => { const zone = this.selected_zone(); @@ -352,18 +216,32 @@ export class ZonesSectionComponent { () => this._display_service.selected_zone_displays().length, ); - constructor() { - effect(() => { - const route_tab = parseZoneTab(this.tab()); - const available_tab = - route_tab === 'templates' && !this.templates_enabled() - ? 'playlists' - : route_tab; - if (available_tab !== this.view_tab()) { - this.view_tab.set(available_tab); - } - }); + public readonly tabs = computed[]>(() => [ + { + id: 'playlists', + label: 'SIGNAGE_MANAGER.NAV_PLAYLISTS', + count: this.playlist_count(), + count_loading: this.playlist_count_loading(), + }, + { + id: 'displays', + label: 'SIGNAGE_MANAGER.NAV_DISPLAYS', + count: this.display_count(), + count_loading: this.display_count_loading(), + }, + ...(this.templates_enabled() + ? [ + { + id: 'templates' as const, + label: 'SIGNAGE_MANAGER.NAV_TEMPLATES', + count: this.template_count(), + count_loading: this.template_count_loading(), + }, + ] + : []), + ]); + constructor() { selectRoutedItem({ id: this.id, list: this._zone_service.all_zones, @@ -389,14 +267,7 @@ export class ZonesSectionComponent { await this._router.navigate(['/zones'], {}); } - public setViewTab(tab: 'playlists' | 'displays' | 'templates') { - if (tab === this.view_tab()) return; - this.view_tab.set(tab); - void this._router.navigate([], { - relativeTo: this._route, - queryParams: { [TAB_QUERY_PARAM]: tab }, - queryParamsHandling: 'merge', - replaceUrl: true, - }); + public setViewTab(tab: ZoneTab) { + this._view_tab.select(tab); } } diff --git a/apps/signage-manager/src/styles.css b/apps/signage-manager/src/styles.css index f3b026ad8b2..e0a48c676b7 100644 --- a/apps/signage-manager/src/styles.css +++ b/apps/signage-manager/src/styles.css @@ -49,6 +49,28 @@ select:focus-visible, } } +/* List and details pages show one panel at a time on small screens */ +@media (max-width: 639px) { + .mobile-hidden { + display: none !important; + } + + .mobile-full { + flex: 1; + } +} + +@media (max-width: 1023px) { + .tablet-hidden { + display: none !important; + } + + .tablet-full { + flex: 1; + min-width: 0; + } +} + :root { --bg: #212121; --transition-delay: 1000ms; diff --git a/apps/signage-manager/src/tests/displays/display-content.component.spec.ts b/apps/signage-manager/src/tests/displays/display-content.component.spec.ts index 10448a7ef7f..edf4d812134 100644 --- a/apps/signage-manager/src/tests/displays/display-content.component.spec.ts +++ b/apps/signage-manager/src/tests/displays/display-content.component.spec.ts @@ -6,8 +6,6 @@ import { SignageDisplayService } from '../../app/displays/signage-display.servic import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; import { SignageContextService } from '../../app/signage-context.service'; -const NOW_S = Math.floor(Date.now() / 1000); - describe('DisplayContentComponent', () => { const selected_display = signal(null); const playlists = signal([]); @@ -114,40 +112,6 @@ describe('DisplayContentComponent', () => { expect(component.display_zones()).toEqual([]); }); - it('marks playlists past their valid_until window as expired', async () => { - const component = await make(); - expect( - component.getStatus({ - id: 'p1', - valid_until: NOW_S - 3600, - } as any), - ).toBe('expired'); - }); - - it('marks playlists before their valid_from window as pending', async () => { - const component = await make(); - expect( - component.getStatus({ - id: 'p1', - valid_from: NOW_S + 3600, - } as any), - ).toBe('pending'); - }); - - it('marks unapproved playlists as awaiting approval', async () => { - playlist_approval_status.set({ p1: false }); - const component = await make(); - expect(component.getStatus({ id: 'p1' } as any)).toBe( - 'awaiting_approval', - ); - }); - - it('returns no status for an approved, in-window playlist', async () => { - playlist_approval_status.set({ p1: true }); - const component = await make(); - expect(component.getStatus({ id: 'p1' } as any)).toBeNull(); - }); - it('adds a playlist to the selected display via the service', async () => { const display = { id: 'd1', playlists: [] }; selected_display.set(display); @@ -158,19 +122,13 @@ describe('DisplayContentComponent', () => { expect(add_playlist).toHaveBeenCalledWith(display); }); - it('removes a playlist from the display and stops the click event', async () => { + it('removes a playlist from the display via the service', async () => { const display = { id: 'd1', playlists: ['p1'] }; selected_display.set(display); const component = await make(); - const event = { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - }; - component.removePlaylist(event as any, 'p1'); + component.removePlaylist('p1'); - expect(event.preventDefault).toHaveBeenCalled(); - expect(event.stopPropagation).toHaveBeenCalled(); expect(remove_playlist).toHaveBeenCalledWith(display, 'p1'); }); diff --git a/apps/signage-manager/src/tests/displays/displays.component.spec.ts b/apps/signage-manager/src/tests/displays/displays.component.spec.ts index fc8574e4475..0f300ae3152 100644 --- a/apps/signage-manager/src/tests/displays/displays.component.spec.ts +++ b/apps/signage-manager/src/tests/displays/displays.component.spec.ts @@ -8,6 +8,7 @@ import { PlaceSystem, show } from '@placeos/ts-client'; import { DisplaysSectionComponent } from '../../app/displays/displays.component'; import { SignageDisplayService } from '../../app/displays/signage-display.service'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; +import { SectionTabsComponent } from '../../app/shared/section-tabs.component'; import { SignageContextService } from '../../app/signage-context.service'; vi.mock('@placeos/ts-client', { spy: true }); @@ -66,7 +67,10 @@ describe('DisplaysSectionComponent', () => { }) .overrideComponent(DisplaysSectionComponent, { set: render_template - ? { imports: [TranslatePipe], schemas: [NO_ERRORS_SCHEMA] } + ? { + imports: [TranslatePipe, SectionTabsComponent], + schemas: [NO_ERRORS_SCHEMA], + } : { template: '' }, }) .compileComponents(); diff --git a/apps/signage-manager/src/tests/groups/signage-group-members.component.spec.ts b/apps/signage-manager/src/tests/groups/signage-group-members.component.spec.ts new file mode 100644 index 00000000000..50945eb936c --- /dev/null +++ b/apps/signage-manager/src/tests/groups/signage-group-members.component.spec.ts @@ -0,0 +1,60 @@ +import { TestBed } from '@angular/core/testing'; +import { + SignageGroupMember, + SignageGroupMembersComponent, +} from '../../app/groups/signage-group-members.component'; + +describe('SignageGroupMembersComponent', () => { + async function render( + kind: 'users' | 'zones', + members: SignageGroupMember[], + ) { + const fixture = TestBed.createComponent( + SignageGroupMembersComponent, + ); + fixture.componentRef.setInput('kind', kind); + fixture.componentRef.setInput('members', members); + await fixture.whenStable(); + return fixture; + } + + it('shows the detail line of users and the denied label of zones', async () => { + const users = await render('users', [ + { + id: 'u1', + name: 'Ann', + detail: 'ann@example.com', + permissions: 0, + row: 'u1', + }, + ]); + expect(users.nativeElement.textContent).toContain('ann@example.com'); + expect(users.nativeElement.textContent).toContain('person'); + + const zones = await render('zones', [ + { id: 'z1', name: 'Lobby', deny: true, permissions: 0, row: 'z1' }, + ]); + expect(zones.nativeElement.textContent).toContain('Denied'); + expect(zones.nativeElement.textContent).not.toContain('person'); + }); + + it('emits the row of the member to edit or remove', async () => { + const fixture = await render('zones', [ + { id: 'z1', name: 'Lobby', permissions: 0, row: 'zone-row' }, + ]); + const edit = vi.fn(); + const remove = vi.fn(); + fixture.componentInstance.edit.subscribe(edit); + fixture.componentInstance.remove.subscribe(remove); + const element: HTMLElement = fixture.nativeElement; + // The add button, then the edit and remove buttons of the row + const [, edit_button, remove_button] = + element.querySelectorAll('button'); + + edit_button?.click(); + remove_button?.click(); + + expect(edit).toHaveBeenCalledWith('zone-row'); + expect(remove).toHaveBeenCalledWith('zone-row'); + }); +}); diff --git a/apps/signage-manager/src/tests/groups/signage-group-tabs.component.spec.ts b/apps/signage-manager/src/tests/groups/signage-group-tabs.component.spec.ts deleted file mode 100644 index cf1feaa3bea..00000000000 --- a/apps/signage-manager/src/tests/groups/signage-group-tabs.component.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { signal } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { SignageGroupAdminService } from '../../app/groups/signage-group-admin.service'; -import { SignageGroupTabsComponent } from '../../app/groups/signage-group-tabs.component'; - -describe('SignageGroupTabsComponent', () => { - const managed_group_tab = signal<'users' | 'zones'>('users'); - const service_stub = { - managed_group_tab, - }; - - function make() { - TestBed.configureTestingModule({ - providers: [ - { provide: SignageGroupAdminService, useValue: service_stub }, - ], - }).overrideComponent(SignageGroupTabsComponent, { - set: { template: '', imports: [] }, - }); - return TestBed.createComponent(SignageGroupTabsComponent) - .componentInstance; - } - - beforeEach(() => managed_group_tab.set('users')); - - it('lists the users and zones tabs', () => { - const component = make(); - expect(component.tabs.map((tab) => tab.id)).toEqual(['users', 'zones']); - }); - - it('switches the active tab through the shared service signal', () => { - const component = make(); - expect(component.active_tab()).toBe('users'); - - component.active_tab.set('zones'); - - expect(managed_group_tab()).toBe('zones'); - }); - - it('moves between the tabs with the arrow, Home and End keys', () => { - const component = make(); - const press = (key: string) => - component.onKeydown(new KeyboardEvent('keydown', { key })); - - press('ArrowRight'); - expect(managed_group_tab()).toBe('zones'); - press('ArrowRight'); - expect(managed_group_tab()).toBe('users'); - press('ArrowLeft'); - expect(managed_group_tab()).toBe('zones'); - press('Home'); - expect(managed_group_tab()).toBe('users'); - press('End'); - expect(managed_group_tab()).toBe('zones'); - press('a'); - expect(managed_group_tab()).toBe('zones'); - }); -}); diff --git a/apps/signage-manager/src/tests/groups/signage-group-users.component.spec.ts b/apps/signage-manager/src/tests/groups/signage-group-users.component.spec.ts index 88d58240c69..df92193fe3e 100644 --- a/apps/signage-manager/src/tests/groups/signage-group-users.component.spec.ts +++ b/apps/signage-manager/src/tests/groups/signage-group-users.component.spec.ts @@ -5,6 +5,7 @@ import { MatDialog } from '@angular/material/dialog'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { SignageGroupAdminService } from '../../app/groups/signage-group-admin.service'; +import { SignageGroupMembersComponent } from '../../app/groups/signage-group-members.component'; import { SignageGroupPermissionsModalComponent } from '../../app/groups/signage-group-permissions-modal.component'; import { SignageGroupUserSelectModalComponent } from '../../app/groups/signage-group-user-select-modal.component'; import { SignageGroupUsersComponent } from '../../app/groups/signage-group-users.component'; @@ -72,7 +73,7 @@ describe('SignageGroupUsersComponent', () => { { provide: SignageGroupAdminService, useValue: service_stub }, { provide: MatDialog, useValue: dialog }, ], - }).overrideComponent(SignageGroupUsersComponent, { + }).overrideComponent(SignageGroupMembersComponent, { set: { imports: [ MatProgressSpinnerModule, diff --git a/apps/signage-manager/src/tests/playlists/playlist-header.component.spec.ts b/apps/signage-manager/src/tests/playlists/playlist-header.component.spec.ts index cadd839d03a..f3e82b58b98 100644 --- a/apps/signage-manager/src/tests/playlists/playlist-header.component.spec.ts +++ b/apps/signage-manager/src/tests/playlists/playlist-header.component.spec.ts @@ -1,7 +1,5 @@ -import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { LocaleService } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; import { PlaylistHeaderComponent } from '../../app/playlists/playlist-header.component'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; import { SignageContextService } from '../../app/signage-context.service'; @@ -44,33 +42,6 @@ describe('PlaylistHeaderComponent', () => { expect(component.total_count()).toBe(450); }); - it('passes the count as the plural so one playlist reads "1 item"', async () => { - playlists_total.set(1); - // Plural 1 picks the ITEM_COUNT_1 form, as the locale service does - const locale = { - get: (key: string, args: { count?: number }, plural?: number) => - key === 'COMMON.ITEM_COUNT' - ? `${args.count} ${plural === 1 ? 'item' : 'items'}` - : key, - }; - await TestBed.configureTestingModule({ - imports: [PlaylistHeaderComponent], - providers: [ - { provide: LocaleService, useValue: locale }, - { provide: SignageContextService, useValue: context_stub }, - { provide: SignagePlaylistService, useValue: playlist_stub }, - ], - }) - .overrideComponent(PlaylistHeaderComponent, { - set: { imports: [TranslatePipe], schemas: [NO_ERRORS_SCHEMA] }, - }) - .compileComponents(); - const fixture = TestBed.createComponent(PlaylistHeaderComponent); - fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toContain('1 item'); - expect(fixture.nativeElement.textContent).not.toContain('1 items'); - }); - it('mirrors the service create permission', async () => { can_create.set(true); const component = await make(); diff --git a/apps/signage-manager/src/tests/shared/assigned-playlists-panel.component.spec.ts b/apps/signage-manager/src/tests/shared/assigned-playlists-panel.component.spec.ts new file mode 100644 index 00000000000..1569a0933af --- /dev/null +++ b/apps/signage-manager/src/tests/shared/assigned-playlists-panel.component.spec.ts @@ -0,0 +1,94 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; +import { AssignedPlaylistsPanelComponent } from '../../app/shared/assigned-playlists-panel.component'; + +const NOW_S = Math.floor(Date.now() / 1000); + +describe('AssignedPlaylistsPanelComponent', () => { + const playlist_approval_status = signal>({}); + const playlist_approval_requested_status = signal>( + {}, + ); + const playlist_stub = { + playlist_approval_status, + playlist_approval_requested_status, + playlist_thumbnail_media: signal>({}), + queuePlaylistMeta: vi.fn(), + }; + + async function make(playlists: unknown[], can_update = true) { + await TestBed.configureTestingModule({ + imports: [AssignedPlaylistsPanelComponent], + providers: [ + provideRouter([]), + { provide: SignagePlaylistService, useValue: playlist_stub }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent( + AssignedPlaylistsPanelComponent, + ); + fixture.componentRef.setInput('playlists', playlists); + fixture.componentRef.setInput('can_update', can_update); + fixture.componentRef.setInput('add_label', 'ADD'); + fixture.componentRef.setInput('remove_label', 'REMOVE'); + fixture.componentRef.setInput('empty_label', 'EMPTY'); + await fixture.whenStable(); + return fixture; + } + + beforeEach(() => { + playlist_approval_status.set({}); + playlist_approval_requested_status.set({}); + }); + + it('classifies playlist status by validity window and approval state', async () => { + playlist_approval_status.set({ p_wait: false, p_review: false }); + playlist_approval_requested_status.set({ p_review: true }); + const component = (await make([])).componentInstance; + + expect( + component.getStatus({ id: 'p', valid_until: NOW_S - 10 } as any), + ).toBe('expired'); + expect( + component.getStatus({ id: 'p', valid_from: NOW_S + 10 } as any), + ).toBe('pending'); + expect(component.getStatus({ id: 'p_wait' } as any)).toBe( + 'awaiting_approval', + ); + expect(component.getStatus({ id: 'p_review' } as any)).toBe( + 'awaiting_review', + ); + expect(component.getStatus({ id: 'p' } as any)).toBeNull(); + }); + + it('shows the disabled and status badges of each playlist', async () => { + const fixture = await make([ + { id: 'p1', name: 'Old', enabled: false, valid_until: NOW_S - 10 }, + ]); + const element: HTMLElement = fixture.nativeElement; + + expect( + element.querySelector('[data-playlist-status="disabled"]'), + ).not.toBeNull(); + expect( + element.querySelector('[data-playlist-status="expired"]'), + ).not.toBeNull(); + }); + + it('emits the id of the playlist to remove', async () => { + const fixture = await make([ + { id: 'p1', name: 'Lobby', enabled: true }, + ]); + const remove = vi.fn(); + fixture.componentInstance.remove.subscribe(remove); + + const element: HTMLElement = fixture.nativeElement; + element + .querySelector('button[aria-label="REMOVE"]') + ?.click(); + + expect(remove).toHaveBeenCalledWith('p1'); + }); +}); diff --git a/apps/signage-manager/src/tests/shared/display-select-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/display-select-modal.component.spec.ts index e803fae9722..ac1788cdffb 100644 --- a/apps/signage-manager/src/tests/shared/display-select-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/display-select-modal.component.spec.ts @@ -1,5 +1,4 @@ import { TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { SignageDisplayService } from '../../app/displays/signage-display.service'; import { DisplaySelectModalComponent } from '../../app/shared/display-select-modal.component'; @@ -22,10 +21,7 @@ describe('DisplaySelectModalComponent', () => { ); await TestBed.configureTestingModule({ imports: [DisplaySelectModalComponent], - providers: [ - { provide: MAT_DIALOG_DATA, useValue: { zone_id: 'zone-1' } }, - { provide: SignageDisplayService, useValue: service }, - ], + providers: [{ provide: SignageDisplayService, useValue: service }], }) .overrideComponent(DisplaySelectModalComponent, { set: { template: '', imports: [] }, @@ -39,9 +35,9 @@ describe('DisplaySelectModalComponent', () => { await flush(); expect(queryDisplays).toHaveBeenCalledWith(''); - expect( - fixture.componentInstance.list.items().map((_) => _.id), - ).toEqual(['d2', 'd1']); + expect(fixture.componentInstance.list.items().map((_) => _.id)).toEqual( + ['d2', 'd1'], + ); }); }); @@ -55,7 +51,6 @@ describe('DisplaySelectModalComponent errors', () => { await TestBed.configureTestingModule({ imports: [DisplaySelectModalComponent], providers: [ - { provide: MAT_DIALOG_DATA, useValue: {} }, { provide: SignageDisplayService, useValue: { queryDisplays } }, ], }).compileComponents(); @@ -77,9 +72,8 @@ describe('DisplaySelectModalComponent errors', () => { fixture.detectChanges(); const element: HTMLElement = fixture.nativeElement; - const retry = element.querySelector( - 'load-error button', - ); + const retry = + element.querySelector('load-error button'); expect(retry).toBeTruthy(); retry?.click(); diff --git a/apps/signage-manager/src/tests/shared/playlist-select-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/playlist-select-modal.component.spec.ts index 10bb6bd4392..926b418aa06 100644 --- a/apps/signage-manager/src/tests/shared/playlist-select-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/playlist-select-modal.component.spec.ts @@ -1,5 +1,4 @@ import { TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; import { PlaylistSelectModalComponent } from '../../app/shared/playlist-select-modal.component'; @@ -22,10 +21,7 @@ describe('PlaylistSelectModalComponent', () => { ); await TestBed.configureTestingModule({ imports: [PlaylistSelectModalComponent], - providers: [ - { provide: MAT_DIALOG_DATA, useValue: { media_id: 'media-1' } }, - { provide: SignagePlaylistService, useValue: service }, - ], + providers: [{ provide: SignagePlaylistService, useValue: service }], }) .overrideComponent(PlaylistSelectModalComponent, { set: { template: '', imports: [] }, @@ -55,7 +51,6 @@ describe('PlaylistSelectModalComponent errors', () => { await TestBed.configureTestingModule({ imports: [PlaylistSelectModalComponent], providers: [ - { provide: MAT_DIALOG_DATA, useValue: {} }, { provide: SignagePlaylistService, useValue: { queryPlaylists }, diff --git a/apps/signage-manager/src/tests/shared/section-header.component.spec.ts b/apps/signage-manager/src/tests/shared/section-header.component.spec.ts new file mode 100644 index 00000000000..0257e80a166 --- /dev/null +++ b/apps/signage-manager/src/tests/shared/section-header.component.spec.ts @@ -0,0 +1,62 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { LocaleService } from '@placeos/common'; +import { GroupBreadcrumbsComponent } from '../../app/shared/group-breadcrumbs.component'; +import { SectionHeaderComponent } from '../../app/shared/section-header.component'; + +describe('SectionHeaderComponent', () => { + // Plural 1 picks the ITEM_COUNT_1 form, as the locale service does + const locale = { + get: (key: string, args: { count?: number }, plural?: number) => + key === 'COMMON.ITEM_COUNT' + ? `${args.count} ${plural === 1 ? 'item' : 'items'}` + : key, + }; + + async function make(count: number | null, can_add = false) { + await TestBed.configureTestingModule({ + imports: [SectionHeaderComponent], + providers: [{ provide: LocaleService, useValue: locale }], + }) + .overrideComponent(SectionHeaderComponent, { + remove: { imports: [GroupBreadcrumbsComponent] }, + add: { schemas: [NO_ERRORS_SCHEMA] }, + }) + .compileComponents(); + const fixture = TestBed.createComponent(SectionHeaderComponent); + fixture.componentRef.setInput('heading', 'TITLE'); + fixture.componentRef.setInput('count', count); + fixture.componentRef.setInput('can_add', can_add); + fixture.componentRef.setInput('add_label', 'ADD_LABEL'); + fixture.componentRef.setInput('add_tooltip', 'ADD_TOOLTIP'); + await fixture.whenStable(); + return fixture; + } + + it('passes the count as the plural so one item reads "1 item"', async () => { + const fixture = await make(1); + expect(fixture.nativeElement.textContent).toContain('1 item'); + expect(fixture.nativeElement.textContent).not.toContain('1 items'); + }); + + it('hides the count while it is not known', async () => { + const fixture = await make(null); + expect(fixture.nativeElement.textContent).not.toContain('item'); + }); + + it('shows a labelled add button only when adding is allowed', async () => { + const hidden = await make(2); + expect(hidden.nativeElement.querySelector('button')).toBeNull(); + TestBed.resetTestingModule(); + + const fixture = await make(2, true); + const add = vi.fn(); + fixture.componentInstance.add.subscribe(add); + const button: HTMLButtonElement = + fixture.nativeElement.querySelector('button'); + + expect(button.getAttribute('aria-label')).toBe('ADD_LABEL'); + button.click(); + expect(add).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/signage-manager/src/tests/shared/section-tabs.component.spec.ts b/apps/signage-manager/src/tests/shared/section-tabs.component.spec.ts new file mode 100644 index 00000000000..6ff41a91574 --- /dev/null +++ b/apps/signage-manager/src/tests/shared/section-tabs.component.spec.ts @@ -0,0 +1,96 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + SectionTab, + SectionTabsComponent, +} from '../../app/shared/section-tabs.component'; + +type Tab = 'users' | 'zones' | 'media'; + +@Component({ + imports: [SectionTabsComponent], + template: ` + + `, +}) +class HostComponent { + public readonly active = signal('users'); + public readonly tabs = signal[]>([ + { id: 'users', label: 'Users' }, + { id: 'zones', label: 'Zones', count: 3, count_loading: true }, + { id: 'media', label: 'Media', count: 2 }, + ]); +} + +describe('SectionTabsComponent', () => { + async function make() { + const fixture = TestBed.createComponent(HostComponent); + await fixture.whenStable(); + const element: HTMLElement = fixture.nativeElement; + const tab = (id: Tab) => + element.querySelector(`#test-${id}-tab`); + const press = async (key: string) => { + element + .querySelector('section-tabs') + ?.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true }), + ); + await fixture.whenStable(); + }; + return { fixture, host: fixture.componentInstance, tab, press }; + } + + it('links each tab to its panel and keeps only the active tab in the tab order', async () => { + const { tab } = await make(); + + expect(tab('users')?.getAttribute('aria-controls')).toBe( + 'test-users-panel', + ); + expect(tab('users')?.getAttribute('aria-selected')).toBe('true'); + expect(tab('users')?.getAttribute('tabindex')).toBe('0'); + expect(tab('zones')?.getAttribute('tabindex')).toBe('-1'); + }); + + it('shows a count badge only for tabs with a count', async () => { + const { tab } = await make(); + + expect(tab('users')?.querySelector('span')).toBeNull(); + expect(tab('zones')?.querySelector('span')?.textContent?.trim()).toBe( + '?', + ); + expect( + tab('zones')?.querySelector('span')?.getAttribute('aria-busy'), + ).toBe('true'); + expect(tab('media')?.querySelector('span')?.textContent?.trim()).toBe( + '2', + ); + }); + + it('selects a tab on click', async () => { + const { fixture, host, tab } = await make(); + + tab('media')?.click(); + await fixture.whenStable(); + + expect(host.active()).toBe('media'); + expect(tab('media')?.getAttribute('aria-selected')).toBe('true'); + }); + + it('moves and focuses tabs with the arrow, Home and End keys', async () => { + const { host, tab, press } = await make(); + + await press('ArrowRight'); + expect(host.active()).toBe('zones'); + expect(document.activeElement).toBe(tab('zones')); + await press('End'); + expect(host.active()).toBe('media'); + await press('ArrowRight'); + expect(host.active()).toBe('users'); + await press('ArrowLeft'); + expect(host.active()).toBe('media'); + await press('Home'); + expect(host.active()).toBe('users'); + await press('a'); + expect(host.active()).toBe('users'); + }); +}); diff --git a/apps/signage-manager/src/tests/templates/template-layout-list.component.spec.ts b/apps/signage-manager/src/tests/templates/template-layout-list.component.spec.ts index 025d0e57d80..1064444b89d 100644 --- a/apps/signage-manager/src/tests/templates/template-layout-list.component.spec.ts +++ b/apps/signage-manager/src/tests/templates/template-layout-list.component.spec.ts @@ -85,24 +85,6 @@ describe('TemplateLayoutListComponent', () => { expect(selected_index()).toBeNull(); }); - it('switches and focuses sidebar tabs with the arrow keys', async () => { - const component = await make(); - const event = { - key: 'ArrowRight', - preventDefault: vi.fn(), - } as unknown as KeyboardEvent; - const items_tab = { focus: vi.fn() } as unknown as HTMLButtonElement; - const details_tab = { - focus: vi.fn(), - } as unknown as HTMLButtonElement; - - component.handleTabKeydown(event, items_tab, details_tab); - - expect(component.view_tab()).toBe('details'); - expect(event.preventDefault).toHaveBeenCalled(); - expect(details_tab.focus).toHaveBeenCalled(); - }); - it('does not reload mappings when a save replaces the same template', async () => { selected_template.set({ id: 'template-1' }); const component = await make(); diff --git a/apps/signage-manager/src/tests/templates/templates.component.spec.ts b/apps/signage-manager/src/tests/templates/templates.component.spec.ts index ab48b105d73..3066b2705a7 100644 --- a/apps/signage-manager/src/tests/templates/templates.component.spec.ts +++ b/apps/signage-manager/src/tests/templates/templates.component.spec.ts @@ -73,35 +73,6 @@ describe('TemplatesSectionComponent', () => { expect(component.view_tab()).toBe('layouts'); }); - it('switches and focuses tabs with the arrow keys', async () => { - const component = await make(); - const layouts_tab = { focus: vi.fn() }; - const details_tab = { focus: vi.fn() }; - const tablist = { - querySelectorAll: () => [ - { focus: vi.fn() }, - layouts_tab, - details_tab, - ], - }; - const event = { - key: 'ArrowRight', - preventDefault: vi.fn(), - currentTarget: { parentElement: tablist }, - } as unknown as KeyboardEvent; - - component.handleTabKeydown(event); - - expect(component.view_tab()).toBe('layouts'); - expect(event.preventDefault).toHaveBeenCalled(); - expect(layouts_tab.focus).toHaveBeenCalled(); - - component.handleTabKeydown(event); - - expect(component.view_tab()).toBe('details'); - expect(details_tab.focus).toHaveBeenCalled(); - }); - it('mirrors the layout list tab into the mobile tabs', async () => { const component = await make(); diff --git a/apps/signage-manager/src/tests/zones/zone-content.component.spec.ts b/apps/signage-manager/src/tests/zones/zone-content.component.spec.ts index daa9c86d09d..e00e8e5c843 100644 --- a/apps/signage-manager/src/tests/zones/zone-content.component.spec.ts +++ b/apps/signage-manager/src/tests/zones/zone-content.component.spec.ts @@ -7,8 +7,6 @@ import { SignageContextService } from '../../app/signage-context.service'; import { SignageZoneService } from '../../app/zones/signage-zone.service'; import { ZoneContentComponent } from '../../app/zones/zone-content.component'; -const NOW_S = Math.floor(Date.now() / 1000); - describe('ZoneContentComponent', () => { const selected_zone = signal(null); const playlists = signal([]); @@ -114,39 +112,18 @@ describe('ZoneContentComponent', () => { expect(component.zone_displays()).toEqual([]); }); - it('classifies playlist status by validity window and approval state', async () => { - playlist_approval_status.set({ p_wait: false }); - const component = await make(); - - expect( - component.getStatus({ id: 'p', valid_until: NOW_S - 10 } as any), - ).toBe('expired'); - expect( - component.getStatus({ id: 'p', valid_from: NOW_S + 10 } as any), - ).toBe('pending'); - expect(component.getStatus({ id: 'p_wait' } as any)).toBe( - 'awaiting_approval', - ); - expect(component.getStatus({ id: 'p' } as any)).toBeNull(); - }); - it('routes add/remove actions through the service for the selected zone', async () => { const zone = { id: 'z1', playlists: ['p1'] }; selected_zone.set(zone); const component = await make(); - const event = { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - }; component.addPlaylist(); component.addDisplay(); - component.removePlaylist(event as any, 'p1'); + component.removePlaylist('p1'); expect(add_playlist).toHaveBeenCalledWith(zone); expect(add_display).toHaveBeenCalledWith(zone); expect(remove_playlist).toHaveBeenCalledWith(zone, 'p1'); - expect(event.preventDefault).toHaveBeenCalled(); }); // The empty states use these icons diff --git a/apps/signage-manager/src/tests/zones/zones.component.spec.ts b/apps/signage-manager/src/tests/zones/zones.component.spec.ts index 05a80aebef4..dd9e075d202 100644 --- a/apps/signage-manager/src/tests/zones/zones.component.spec.ts +++ b/apps/signage-manager/src/tests/zones/zones.component.spec.ts @@ -5,6 +5,7 @@ import { TranslatePipe } from '@placeos/components'; import { PlaceZone, showZone } from '@placeos/ts-client'; import { SignageDisplayService } from '../../app/displays/signage-display.service'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; +import { SectionTabsComponent } from '../../app/shared/section-tabs.component'; import { SignageContextService } from '../../app/signage-context.service'; import { SignageTemplateService } from '../../app/templates/signage-template.service'; import { SignageZoneService } from '../../app/zones/signage-zone.service'; @@ -67,7 +68,10 @@ describe('ZonesSectionComponent', () => { }) .overrideComponent(ZonesSectionComponent, { set: render_template - ? { imports: [TranslatePipe], schemas: [NO_ERRORS_SCHEMA] } + ? { + imports: [TranslatePipe, SectionTabsComponent], + schemas: [NO_ERRORS_SCHEMA], + } : { template: '' }, }) .compileComponents(); From e995ff872941709389fb3c77665b0fc0da3b5309 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 20:24:58 +1100 Subject: [PATCH 19/21] refactor(signage-manager): share assignment, approval and schedule text - Display and zone services share playlist assignment. - Playlist undo has a confirm step, like template undo. - One translated schedule summary replaces the English-only copy. Times follow the app's 24-hour setting. - One confirm helper for removes shows progress and always closes. - Change keys are counters, so two changes in one millisecond are not lost. --- .../src/app/branding/branding.component.ts | 3 +- .../app/displays/display-list.component.ts | 37 +- .../src/app/displays/display-status.util.ts | 45 ++ .../src/app/displays/displays.component.ts | 7 +- .../app/displays/signage-display.service.ts | 241 ++++------- .../app/groups/signage-group-admin.service.ts | 54 +-- .../image-gen/image-gen-modal.component.ts | 15 +- .../src/app/image-gen/image-gen.service.ts | 5 +- .../app/media/media-list-header.component.ts | 9 +- .../src/app/media/media-list.component.ts | 12 +- .../src/app/media/signage-media.service.ts | 306 +++++++------- .../playlist-item-details.component.ts | 32 +- .../app/playlists/playlist-items.component.ts | 34 +- .../app/playlists/signage-playlist.service.ts | 205 +++++----- .../schedules/schedule-timeline.component.ts | 43 +- .../app/schedules/signage-schedule.util.ts | 17 +- .../src/app/shared/confirm-then-run.ts | 41 ++ .../app/shared/media-edit-modal.component.ts | 53 +-- .../src/app/shared/media-labels.util.ts | 46 +++ .../shared/media-preview-modal.component.ts | 40 +- .../playlist-approve-modal.component.ts | 33 +- .../src/app/shared/playlist-assignments.ts | 161 ++++++++ .../shared/playlist-edit-modal.component.ts | 96 +---- .../playlist-item-schedule-modal.component.ts | 43 +- ...aylist-request-approval-modal.component.ts | 35 +- .../playlist-schedule-form.component.ts | 338 +++++---------- .../src/app/shared/save-hotkey.ts | 26 ++ .../src/app/shared/schedule-summary.util.ts | 386 ++++++++++++++++++ .../src/app/shared/stored-signal.ts | 51 +++ .../shared/template-edit-modal.component.ts | 20 +- .../app/shared/template-mappings.component.ts | 9 +- .../src/app/signage-context.service.ts | 117 ++++-- .../src/app/signage-playlist.util.ts | 209 +--------- .../src/app/signage-url.util.ts | 20 + .../app/templates/signage-template.service.ts | 166 +++----- .../template-layout-list.component.ts | 7 +- .../templates/template-preview.component.ts | 13 +- .../src/app/zones/signage-zone.service.ts | 165 ++------ .../tests/media/signage-media.service.spec.ts | 2 + .../playlist-item-details.component.spec.ts | 4 +- .../signage-playlist.service.spec.ts | 36 ++ .../playlist-approve-modal.component.spec.ts | 42 +- .../playlist-edit-modal.component.spec.ts | 44 +- ...list-item-schedule-modal.component.spec.ts | 28 +- ...t-request-approval-modal.component.spec.ts | 38 +- .../shared/schedule-summary.util.spec.ts | 143 +++++++ .../template-mappings.component.spec.ts | 4 +- .../src/tests/signage-playlist.util.spec.ts | 60 +-- .../src/tests/signage-zone-search.spec.ts | 2 +- shared/assets/locale/ar.json | 16 +- shared/assets/locale/en-AU.json | 14 + shared/assets/locale/en-GB.json | 16 +- shared/assets/locale/en-US.json | 16 +- shared/assets/locale/es.json | 16 +- shared/assets/locale/fr-CA.json | 16 +- shared/assets/locale/fr.json | 16 +- shared/assets/locale/it.json | 16 +- shared/assets/locale/ja-JP.json | 16 +- shared/assets/locale/pt.json | 16 +- shared/assets/locale/zh.json | 16 +- 60 files changed, 1963 insertions(+), 1754 deletions(-) create mode 100644 apps/signage-manager/src/app/shared/confirm-then-run.ts create mode 100644 apps/signage-manager/src/app/shared/media-labels.util.ts create mode 100644 apps/signage-manager/src/app/shared/playlist-assignments.ts create mode 100644 apps/signage-manager/src/app/shared/save-hotkey.ts create mode 100644 apps/signage-manager/src/app/shared/schedule-summary.util.ts create mode 100644 apps/signage-manager/src/app/shared/stored-signal.ts create mode 100644 apps/signage-manager/src/tests/shared/schedule-summary.util.spec.ts diff --git a/apps/signage-manager/src/app/branding/branding.component.ts b/apps/signage-manager/src/app/branding/branding.component.ts index 3b02a90a1b9..bc4c19cd17c 100644 --- a/apps/signage-manager/src/app/branding/branding.component.ts +++ b/apps/signage-manager/src/app/branding/branding.component.ts @@ -28,6 +28,7 @@ import { } from '../image-gen/image-gen.types'; import { actionError } from '../image-gen/image-gen.util'; import { SignageContextService } from '../signage-context.service'; +import { uploadUrl } from '../signage-url.util'; import { brandEditingOn, canEditBrandKit } from './brand-access'; import { BRAND_FONTS, ensureBrandFont } from './brand-fonts'; @@ -495,7 +496,7 @@ export class BrandingComponent implements OnInit { public logoUrl(slot: ImageGenLogoSlot) { const id = this.logos()[slot]; - return id ? `/api/engine/v2/uploads/${encodeURIComponent(id)}/url` : ''; + return id ? uploadUrl(id) : ''; } public other(slot: ImageGenLogoSlot): ImageGenLogoSlot { diff --git a/apps/signage-manager/src/app/displays/display-list.component.ts b/apps/signage-manager/src/app/displays/display-list.component.ts index 6e85f6c2d70..f8bf4b7fec5 100644 --- a/apps/signage-manager/src/app/displays/display-list.component.ts +++ b/apps/signage-manager/src/app/displays/display-list.component.ts @@ -1,4 +1,3 @@ -import { DatePipe } from '@angular/common'; import { Component, DestroyRef, @@ -15,15 +14,17 @@ import { MatInputModule } from '@angular/material/input'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; import { - DateFromPipe, IconComponent, LoadErrorComponent, TranslatePipe, } from '@placeos/components'; -import { isSameDay } from 'date-fns'; import { IntersectDirective } from '../shared/intersect.directive'; import { scrollSelectionIntoView } from '../shared/scroll-selection.util'; -import { isDisplayOnline } from './display-status.util'; +import { + displayLastSeen, + displayStatusKey, + isDisplayOnline, +} from './display-status.util'; import { SignageDisplayService } from './signage-display.service'; @Component({ @@ -184,8 +185,7 @@ export class DisplayListComponent { // Ticks each minute so a display that stops checking in turns offline // without a reload. private readonly _now = signal(Date.now()); - private readonly _date_from = new DateFromPipe(); - private readonly _date = new DatePipe(inject(LOCALE_ID)); + private readonly _locale = inject(LOCALE_ID); constructor() { const timer = setInterval(() => this._now.set(Date.now()), 60 * 1000); @@ -212,26 +212,15 @@ export class DisplayListComponent { /** Translation key for the status tooltip of a display */ public statusLabel(display: { signage_last_seen?: number }) { - if (!display.signage_last_seen) { - return 'SIGNAGE_MANAGER.DISPLAY_STATUS_NEVER_SEEN'; - } - return this.isOnline(display) - ? 'SIGNAGE_MANAGER.DISPLAY_STATUS_ONLINE' - : 'SIGNAGE_MANAGER.DISPLAY_STATUS_OFFLINE'; + return displayStatusKey(display.signage_last_seen, this._now()); } - /** - * When the display's player last checked in: minutes ago within the last - * hour, the time earlier today, and the date and time before today. - */ + /** When the display's player last checked in */ public lastSeen(display: { signage_last_seen?: number }) { - const now = this._now(); - if (!display.signage_last_seen) return ''; - const last_seen = display.signage_last_seen * 1000; - if (now - last_seen < 60 * 60 * 1000) { - return this._date_from.transform(last_seen); - } - const date_format = isSameDay(last_seen, now) ? 'shortTime' : 'short'; - return this._date.transform(last_seen, date_format) || ''; + return displayLastSeen( + display.signage_last_seen, + this._now(), + this._locale, + ); } } diff --git a/apps/signage-manager/src/app/displays/display-status.util.ts b/apps/signage-manager/src/app/displays/display-status.util.ts index 37a40b66353..97b22969f15 100644 --- a/apps/signage-manager/src/app/displays/display-status.util.ts +++ b/apps/signage-manager/src/app/displays/display-status.util.ts @@ -1,3 +1,7 @@ +import { formatDate } from '@angular/common'; +import { DateFromPipe } from '@placeos/components'; +import { isSameDay } from 'date-fns'; + /** Minutes without a player check-in before a display counts as offline */ const DISPLAY_OFFLINE_MINUTES = 5; @@ -15,3 +19,44 @@ export function isDisplayOnline( Math.abs(now - last_seen * 1000) <= DISPLAY_OFFLINE_MINUTES * 60 * 1000 ); } + +/** + * Translation key of the status of a display. The keys take the last seen + * time as `time`. + * @param last_seen Last player check-in in Unix seconds, 0 or omitted if never seen + * @param now Current time in milliseconds + */ +export function displayStatusKey( + last_seen: number | undefined, + now = Date.now(), +) { + if (!last_seen) return 'SIGNAGE_MANAGER.DISPLAY_STATUS_NEVER_SEEN'; + return isDisplayOnline(last_seen, now) + ? 'SIGNAGE_MANAGER.DISPLAY_STATUS_ONLINE' + : 'SIGNAGE_MANAGER.DISPLAY_STATUS_OFFLINE'; +} + +const date_from = new DateFromPipe(); + +/** + * When a display's player last checked in: relative within the last hour, + * the time earlier today, and the date and time before today. Empty when + * it was never seen. + * @param last_seen Last player check-in in Unix seconds + * @param now Current time in milliseconds + * @param locale Locale of the date and time + */ +export function displayLastSeen( + last_seen: number | undefined, + now: number, + locale: string, +) { + if (!last_seen) return ''; + const seen_at = last_seen * 1000; + if (now - seen_at < 60 * 60 * 1000) return date_from.transform(seen_at); + return formatDate( + seen_at, + isSameDay(seen_at, now) ? 'shortTime' : 'short', + locale, + ); +} diff --git a/apps/signage-manager/src/app/displays/displays.component.ts b/apps/signage-manager/src/app/displays/displays.component.ts index bc4e6168b68..45ad3e583bb 100644 --- a/apps/signage-manager/src/app/displays/displays.component.ts +++ b/apps/signage-manager/src/app/displays/displays.component.ts @@ -13,6 +13,7 @@ import { SectionTabsComponent, } from '../shared/section-tabs.component'; import { SignageContextService } from '../signage-context.service'; +import { signagePlayerUrl } from '../signage-url.util'; import { DisplayContentComponent } from './display-content.component'; import { DisplayHeaderComponent } from './display-header.component'; import { DisplayListComponent } from './display-list.component'; @@ -246,8 +247,10 @@ export class DisplaysSectionComponent { public readonly panel_link = computed(() => { const display = this.selected_display(); if (!display?.id) return ''; - const signage_path = this.signage_path() || '/signage'; - return `${signage_path.replace(/\/$/, '')}/#/signage/${encodeURIComponent(display.id)}?debug=true`; + return signagePlayerUrl( + this.signage_path(), + `signage/${encodeURIComponent(display.id)}`, + ); }); constructor() { diff --git a/apps/signage-manager/src/app/displays/signage-display.service.ts b/apps/signage-manager/src/app/displays/signage-display.service.ts index d894f0fc114..5b26fee8462 100644 --- a/apps/signage-manager/src/app/displays/signage-display.service.ts +++ b/apps/signage-manager/src/app/displays/signage-display.service.ts @@ -16,7 +16,6 @@ import { notifySuccess, OrganisationService, } from '@placeos/common'; -import { openConfirmModal } from '@placeos/components'; import { PlaceSystem, PlaceZone, @@ -27,8 +26,10 @@ import { SignagePlaylist, } from '@placeos/ts-client'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; +import { confirmThenRun } from '../shared/confirm-then-run'; import { decodeEntityNames } from '../shared/decode-entity-names.util'; import { PagedList } from '../shared/paged-list'; +import { PlaylistAssignments } from '../shared/playlist-assignments'; import { SignageContextService } from '../signage-context.service'; import { dialogClosed, @@ -143,15 +144,6 @@ export class SignageDisplayService { }); }); - // Local edits only bridge the gap until the lists reload. Drop them when - // the group or data changes, so a stale copy never hides newer server data - // or sends an old version with the next patch. - private readonly _clear_overrides = effect(() => { - this._context.api_group_id(); - this._context.data_change(); - untracked(() => this._display_overrides.set({})); - }); - /** * Paged queries for the picker modals, which search on their own without * disturbing the lists behind them. Null when the user may not query. @@ -186,6 +178,22 @@ export class SignageDisplayService { }, ); + private readonly _assignments = new PlaylistAssignments({ + context: this._context, + playlist_service: this._playlist_service, + dialog: this._dialog, + overrides: this._display_overrides, + selected: this.selected_display, + target: 'display_id', + already_assigned_key: 'SIGNAGE_MANAGER.SVC_PLAYLIST_IN_DISPLAY', + add_error_key: 'SIGNAGE_MANAGER.SVC_PLAYLIST_ADD_DISPLAY_ERROR', + save_playlists: (display, playlists) => + updateSignageDisplay(display.id, { + playlists, + version: display.version, + }), + }); + // The listing itself, which is whatever page(s) of the (possibly // searched) query have been loaded so far. Local edits are applied over // the loaded items, but never add a display the query didn't return. @@ -305,20 +313,6 @@ export class SignageDisplayService { () => !!this._selected_display_template_mappings.error(), ); - /** - * Keep a saved display until the lists reload. - * @returns The display with its names decoded, to use for selection - */ - private _cacheDisplay(display: PlaceSystem) { - const item = decodeEntityNames(display); - if (!item?.id) return item; - this._display_overrides.update((state) => ({ - ...state, - [item.id]: item, - })); - return item; - } - private _addDisplayToList(display: PlaceSystem) { const item = decodeEntityNames(display); if (!item?.id) return item; @@ -330,7 +324,7 @@ export class SignageDisplayService { ...cache, [item.id]: item, })); - return this._cacheDisplay(item); + return this._assignments.cache(item); } private _removeDisplayFromList(display_id: string) { @@ -421,7 +415,14 @@ export class SignageDisplayService { ) ) return false; - const result = await openConfirmModal( + const used_elsewhere = !!( + display.map_id || + display.email || + display.modules.length || + display.module_list.length + ); + const removed = await confirmThenRun( + this._dialog, { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_DISPLAY_TITLE'), content: i18n('SIGNAGE_MANAGER.SVC_DELETE_NAMED', { @@ -429,27 +430,16 @@ export class SignageDisplayService { }), icon: { content: 'delete' }, }, - this._dialog, - ); - if (result.reason !== 'done') return false; - const used_elsewhere = !!( - display.map_id || - display.email || - display.modules.length || - display.module_list.length - ); - const request: Promise = used_elsewhere - ? updateSignageDisplay(display.id, { signage: false }) - : removeSystem(display.id); - const removed = await request.then( - () => true, - () => false, + { + loading_key: 'SIGNAGE_MANAGER.SVC_REMOVING', + error_key: 'SIGNAGE_MANAGER.SVC_DISPLAY_REMOVE_ERROR', + action: () => + used_elsewhere + ? updateSignageDisplay(display.id, { signage: false }) + : removeSystem(display.id), + }, ); - result.close(); - if (!removed) { - notifyError(i18n('SIGNAGE_MANAGER.SVC_DISPLAY_REMOVE_ERROR')); - return false; - } + if (!removed) return false; this._removeDisplayFromList(display.id); if (this.selected_display()?.id === display.id) { this.selected_display.set(null); @@ -481,15 +471,9 @@ export class SignageDisplayService { } public async addDisplayToZone(zone: PlaceZone) { - if (!this._canAssign()) return; - const { DisplaySelectModalComponent } = - await import('../shared/display-select-modal.component'); - const ref = this._dialog.open(DisplaySelectModalComponent, { - panelClass: 'mobile-fullscreen', - }); - const display_id = await dialogClosed(ref); - if (!display_id) return; - const display = await this._findDisplay(display_id); + if (!this._assignments.canAssign()) return; + const display = await this._pickDisplay(); + if (display === undefined) return; if (!display) { notifyError(i18n('SIGNAGE_MANAGER.SVC_ASSIGNMENT_ERROR')); return; @@ -499,23 +483,23 @@ export class SignageDisplayService { return; } const zone_ids = await this._zone_service.zoneIdsWithAncestors([zone]); - await this._saveDisplay( - display, - { zones: [...new Set([...(display.zones || []), ...zone_ids])] }, + await this._assignments.save( + () => + updateSignageDisplay(display.id, { + zones: [ + ...new Set([...(display.zones || []), ...zone_ids]), + ], + version: display.version, + }), 'SIGNAGE_MANAGER.SVC_DISPLAY_ADDED_ZONE', ); } public async addPlaylistToDisplay(display: PlaceSystem) { - if (!this._canAssign()) return; - const { PlaylistSelectModalComponent } = - await import('../shared/playlist-select-modal.component'); - const ref = this._dialog.open(PlaylistSelectModalComponent, { - panelClass: 'mobile-fullscreen', - }); - const playlist_id = await dialogClosed(ref); + if (!this._assignments.canAssign()) return; + const playlist_id = await this._assignments.pickPlaylist(); if (!playlist_id) return; - const updated = await this._addDisplayPlaylist( + const updated = await this._assignments.add( display, { playlist_id }, 'SIGNAGE_MANAGER.SVC_PLAYLIST_ADDED_DISPLAY', @@ -524,44 +508,38 @@ export class SignageDisplayService { } public async addDisplayToPlaylist(playlist: SignagePlaylist) { - if (!this._canAssign()) return; - const { DisplaySelectModalComponent } = - await import('../shared/display-select-modal.component'); - const ref = this._dialog.open(DisplaySelectModalComponent, { - panelClass: 'mobile-fullscreen', - }); - const display_id = await dialogClosed(ref); - if (!display_id) return; - const display = await this._findDisplay(display_id); + if (!this._assignments.canAssign()) return; + const display = await this._pickDisplay(); + if (display === undefined) return; if (!display) { notifyError(i18n('SIGNAGE_MANAGER.SVC_PLAYLIST_ADD_DISPLAY_ERROR')); return; } - const updated = await this._addDisplayPlaylist( + const updated = await this._assignments.add( display, { playlist_id: playlist.id, playlist }, 'SIGNAGE_MANAGER.SVC_DISPLAY_ADDED_PLAYLIST', ); - this._updateSelection(updated); + this._assignments.updateSelection(updated); } public async removeDisplayFromPlaylist( playlist: SignagePlaylist, display: PlaceSystem, ) { - const updated = await this._removeDisplayPlaylist( + const updated = await this._assignments.remove( display, playlist.id, 'SIGNAGE_MANAGER.SVC_DISPLAY_REMOVED_PLAYLIST', ); - this._updateSelection(updated); + this._assignments.updateSelection(updated); } public async removePlaylistFromDisplay( display: PlaceSystem, playlist_id: string, ) { - const updated = await this._removeDisplayPlaylist( + const updated = await this._assignments.remove( display, playlist_id, 'SIGNAGE_MANAGER.SVC_PLAYLIST_REMOVED_DISPLAY', @@ -569,105 +547,26 @@ export class SignageDisplayService { if (updated) this.selected_display.set(updated); } - private _canAssign() { - return this._context.requirePermission( - this._context.can_update(), - 'SIGNAGE_MANAGER.SVC_NO_UPDATE_ASSIGNMENTS', - ); - } - /** - * A display from the loaded list, or read by ID. The pickers search the - * backend, so the choice may be a display the list never loaded. + * Let the user pick a display. The picker searches the backend, so the + * choice may be a display the list never loaded. + * @returns The display, null when it cannot be read, or undefined when + * the picker was closed */ - private async _findDisplay(display_id: string) { + private async _pickDisplay() { + const { DisplaySelectModalComponent } = + await import('../shared/display-select-modal.component'); + const ref = this._dialog.open(DisplaySelectModalComponent, { + panelClass: 'mobile-fullscreen', + }); + const display_id = await dialogClosed(ref); + if (!display_id) return undefined; return ( this.displays().find(({ id }) => id === display_id) || showSignageDisplay(display_id).catch(() => null) ); } - /** Show a saved display in place of the selected display, when it is - * that display */ - private _updateSelection(display: PlaceSystem | null) { - if (display && this.selected_display()?.id === display.id) { - this.selected_display.set(display); - } - } - - /** - * Add a playlist to a display, after a check for takeover conflicts. - * @returns The saved display, or null when nothing was saved - */ - private async _addDisplayPlaylist( - display: PlaceSystem, - change: { playlist_id: string; playlist?: SignagePlaylist }, - success_key: string, - ) { - if (display.playlists?.includes(change.playlist_id)) { - notifyError(i18n('SIGNAGE_MANAGER.SVC_PLAYLIST_IN_DISPLAY')); - return null; - } - const confirmed = await this._playlist_service.confirmTakeoverChange({ - ...change, - display_id: display.id, - }); - if (!confirmed) return null; - return this._saveDisplay( - display, - { playlists: [...(display.playlists || []), change.playlist_id] }, - success_key, - 'SIGNAGE_MANAGER.SVC_PLAYLIST_ADD_DISPLAY_ERROR', - ); - } - - /** - * Take a playlist off a display. - * @returns The saved display, or null when nothing was saved - */ - private async _removeDisplayPlaylist( - display: PlaceSystem, - playlist_id: string, - success_key: string, - ) { - if (!this._canAssign()) return null; - return this._saveDisplay( - display, - { - playlists: (display.playlists || []).filter( - (id) => id !== playlist_id, - ), - }, - success_key, - ); - } - - /** - * Save the playlists or zones of a display and keep the result until the - * lists reload. - * @param error_key Translation key of the error to show when it fails - * @returns The saved display, or null after showing an error when it fails - */ - private async _saveDisplay( - display: PlaceSystem, - data: Pick, 'playlists' | 'zones'>, - success_key: string, - error_key = 'SIGNAGE_MANAGER.SVC_ASSIGNMENT_ERROR', - ) { - const updated = await updateSignageDisplay(display.id, { - ...data, - version: display.version, - }).catch(() => null); - if (!updated) { - notifyError(i18n(error_key)); - return null; - } - const saved = this._cacheDisplay(updated); - this._context.changed(); - notifySuccess(i18n(success_key)); - return saved; - } - constructor() { // Playlists of the selected display and its zones can be outside the // loaded pages diff --git a/apps/signage-manager/src/app/groups/signage-group-admin.service.ts b/apps/signage-manager/src/app/groups/signage-group-admin.service.ts index f291a503224..8057a5029ce 100644 --- a/apps/signage-manager/src/app/groups/signage-group-admin.service.ts +++ b/apps/signage-manager/src/app/groups/signage-group-admin.service.ts @@ -10,7 +10,7 @@ import { } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { i18n, notifyError, notifySuccess, notifyWarn } from '@placeos/common'; -import { openConfirmModal } from '@placeos/components'; +import { type ConfirmModalData, openConfirmModal } from '@placeos/components'; import { addGroup, addGroupUser, @@ -35,6 +35,7 @@ import { updateGroupUser, updateGroupZone, } from '@placeos/ts-client'; +import { confirmThenRun } from '../shared/confirm-then-run'; import { decodeEntityNames } from '../shared/decode-entity-names.util'; import { groupHierarchy, @@ -425,7 +426,7 @@ export class SignageGroupAdminService { notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_MANAGE_GROUP')); return; } - const result = await openConfirmModal( + const removed = await this._confirmGroupRemove( { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_GROUP_TITLE'), content: i18n('SIGNAGE_MANAGER.SVC_DELETE_NAMED', { @@ -433,13 +434,7 @@ export class SignageGroupAdminService { }), icon: { content: 'delete' }, }, - this._dialog, - ); - if (result.reason !== 'done') return; - const removed = await this._saveGroupChange( - removeGroup(group.id) - .then(() => true) - .finally(() => result.close()), + () => removeGroup(group.id), 'SIGNAGE_MANAGER.SVC_ERR_REMOVE_GROUP', 'SIGNAGE_MANAGER.SVC_GROUP_REMOVED', ); @@ -508,7 +503,7 @@ export class SignageGroupAdminService { * remove themselves, as they can lose access to the group. */ public async removeManagedGroupUser(item: PlaceGroupUser) { if (!this._context.canManageSignageGroup(item.group_id)) return; - const result = await openConfirmModal( + await this._confirmGroupRemove( { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_USER_TITLE'), content: this._isCurrentUser(item.user_id) @@ -518,13 +513,7 @@ export class SignageGroupAdminService { }), icon: { content: 'delete' }, }, - this._dialog, - ); - if (result.reason !== 'done') return; - await this._saveGroupChange( - removeGroupUser(item.user_id, item.group_id).finally(() => - result.close(), - ), + () => removeGroupUser(item.user_id, item.group_id), 'SIGNAGE_MANAGER.SVC_ERR_REMOVE_USER', 'SIGNAGE_MANAGER.SVC_USER_REMOVED', ); @@ -566,7 +555,7 @@ export class SignageGroupAdminService { public async removeManagedGroupZone(item: PlaceGroupZone) { if (!this._context.canManageSignageGroup(item.group_id)) return; - const result = await openConfirmModal( + await this._confirmGroupRemove( { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_ZONE_TITLE'), content: i18n('SIGNAGE_MANAGER.SVC_REMOVE_NAMED_FROM_GROUP', { @@ -574,18 +563,33 @@ export class SignageGroupAdminService { }), icon: { content: 'delete' }, }, - this._dialog, - ); - if (result.reason !== 'done') return; - await this._saveGroupChange( - removeGroupZone(item.group_id, item.zone_id).finally(() => - result.close(), - ), + () => removeGroupZone(item.group_id, item.zone_id), 'SIGNAGE_MANAGER.SVC_ERR_REMOVE_ZONE', 'SIGNAGE_MANAGER.SVC_ZONE_REMOVED', ); } + /** + * Ask to confirm a group remove, run it, then reload the group lists. + * @returns Whether the remove was confirmed and succeeded + */ + private async _confirmGroupRemove( + confirm: ConfirmModalData, + action: () => Promise, + error_key: string, + success_key: string, + ) { + const removed = await confirmThenRun(this._dialog, confirm, { + loading_key: 'SIGNAGE_MANAGER.SVC_REMOVING', + error_key, + action, + }); + if (!removed) return false; + this._context.reloadSignageGroups(); + notifySuccess(i18n(success_key)); + return true; + } + /** * Wait for a group save, then reload the group lists and confirm it. * Shows an error and returns null when the save fails, so callers bound diff --git a/apps/signage-manager/src/app/image-gen/image-gen-modal.component.ts b/apps/signage-manager/src/app/image-gen/image-gen-modal.component.ts index 7ba0e3e0488..d4386825404 100644 --- a/apps/signage-manager/src/app/image-gen/image-gen-modal.component.ts +++ b/apps/signage-manager/src/app/image-gen/image-gen-modal.component.ts @@ -34,6 +34,7 @@ import { brandEditingOn, canEditBrandKit } from '../branding/brand-access'; import { SignageMediaService } from '../media/signage-media.service'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; import { SignageContextService } from '../signage-context.service'; +import { uploadUrl } from '../signage-url.util'; import { ImageGenLayerControlsComponent, newTextBlock, @@ -723,7 +724,7 @@ export class ImageGenModalComponent implements OnDestroy { /** the image being changed, so the brief is not written blind */ public readonly source_url = computed(() => { const id = this._data.source_upload_id; - return id ? `/api/engine/v2/uploads/${encodeURIComponent(id)}/url` : ''; + return id ? uploadUrl(id) : ''; }); public readonly has_logo = computed( () => !!this._image_gen.capabilities()?.logo_layer, @@ -768,9 +769,7 @@ export class ImageGenModalComponent implements OnDestroy { job_id: id, index, upload_id: image.upload_id, - url: - image.url || - `/api/engine/v2/uploads/${encodeURIComponent(image.upload_id)}/url`, + url: image.url || uploadUrl(image.upload_id), width: image.width, height: image.height, version, @@ -1174,9 +1173,7 @@ export class ImageGenModalComponent implements OnDestroy { // briefly empty if (media.thumbnail_id) { await this._image_gen - .loadImage( - `/api/engine/v2/uploads/${media.thumbnail_id}/url`, - ) + .loadImage(uploadUrl(media.thumbnail_id)) .catch(() => ''); } this._dialog_ref.close(media); @@ -1291,9 +1288,7 @@ export class ImageGenModalComponent implements OnDestroy { private _readUpload(id?: string) { if (!id) return Promise.resolve(''); - return this._image_gen - .loadImage(`/api/engine/v2/uploads/${encodeURIComponent(id)}/url`) - .catch(() => ''); + return this._image_gen.loadImage(uploadUrl(id)).catch(() => ''); } private _name() { diff --git a/apps/signage-manager/src/app/image-gen/image-gen.service.ts b/apps/signage-manager/src/app/image-gen/image-gen.service.ts index 5022eda8afb..13d09e02ed9 100644 --- a/apps/signage-manager/src/app/image-gen/image-gen.service.ts +++ b/apps/signage-manager/src/app/image-gen/image-gen.service.ts @@ -11,6 +11,7 @@ import { loadAuthenticatedImage } from '@placeos/components'; import { showMetadata, updateMetadata } from '@placeos/ts-client'; import { flipLightness, inkIsLight } from '../branding/logo-variant'; +import { uploadUrl } from '../signage-url.util'; import { cancelSignageImageGenJob, claimSignageImageGenImage, @@ -209,9 +210,7 @@ export class ImageGenService extends AsyncHandler { throw new UserFacingError( i18n('SIGNAGE_MANAGER.IMAGE_GEN_NO_LOGO_YET'), ); - const url = await this.loadImage( - `/api/engine/v2/uploads/${encodeURIComponent(source_id)}/url`, - ); + const url = await this.loadImage(uploadUrl(source_id)); const upload_id = await this._flip(url, target); return this.saveBrandKit({ [logoKey(target)]: upload_id, diff --git a/apps/signage-manager/src/app/media/media-list-header.component.ts b/apps/signage-manager/src/app/media/media-list-header.component.ts index 77a938ea5ef..5820032814b 100644 --- a/apps/signage-manager/src/app/media/media-list-header.component.ts +++ b/apps/signage-manager/src/app/media/media-list-header.component.ts @@ -17,6 +17,7 @@ import { SignagePlugin } from '@placeos/ts-client'; import { ImageGenService } from '../image-gen/image-gen.service'; import { GroupBreadcrumbsComponent } from '../shared/group-breadcrumbs.component'; import { MediaAddModalComponent } from '../shared/media-add-modal.component'; +import { MEDIA_TYPE_LABELS } from '../shared/media-labels.util'; import { SignageContextService } from '../signage-context.service'; import { SignagePluginService } from '../signage-plugin.service'; import { normaliseWebPageUrl } from '../signage-url.util'; @@ -27,7 +28,6 @@ import { MEDIA_TYPE_FILTERS, type MediaExpiryFilter, type MediaSort, - type MediaTypeFilter, type MediaViewOptions, } from './media-view.util'; import { SignageMediaService } from './signage-media.service'; @@ -450,12 +450,7 @@ export class MediaListHeaderComponent { expiry: 'SIGNAGE_MANAGER.MEDIA_SORT_EXPIRY', }; // Same labels as the type badge on media cards - public readonly type_labels: Record = { - image: 'COMMON.IMAGE', - video: 'COMMON.VIDEO', - webpage: 'COMMON.WEBPAGE', - plugin: 'SIGNAGE_MANAGER.TYPE_PLUGIN', - }; + public readonly type_labels = MEDIA_TYPE_LABELS; public readonly expiry_labels: Record = { expiring: 'SIGNAGE_MANAGER.MEDIA_FILTER_EXPIRING', expired: 'SIGNAGE_MANAGER.STATUS_EXPIRED', diff --git a/apps/signage-manager/src/app/media/media-list.component.ts b/apps/signage-manager/src/app/media/media-list.component.ts index 07a2cf9926e..78c0a371bc4 100644 --- a/apps/signage-manager/src/app/media/media-list.component.ts +++ b/apps/signage-manager/src/app/media/media-list.component.ts @@ -27,6 +27,7 @@ import { import { SignageMedia } from '@placeos/ts-client'; import { ImageGenService } from '../image-gen/image-gen.service'; import { IntersectDirective } from '../shared/intersect.directive'; +import { mediaTypeLabel } from '../shared/media-labels.util'; import { MediaThumbnailComponent } from '../shared/media-thumbnail.component'; import { SignageContextService } from '../signage-context.service'; import { playlistMediaThumbnailUrl } from '../signage-playlist.util'; @@ -946,16 +947,7 @@ export class MediaListComponent implements OnInit { } public typeLabelKey(item: SignageMedia): string { - switch (item.media_type) { - case 'image': - return 'COMMON.IMAGE'; - case 'webpage': - return 'COMMON.WEBPAGE'; - case 'plugin': - return 'SIGNAGE_MANAGER.TYPE_PLUGIN'; - default: - return 'COMMON.VIDEO'; - } + return mediaTypeLabel(item.media_type) ?? 'COMMON.VIDEO'; } public typeBadgeClass(item: SignageMedia): string { diff --git a/apps/signage-manager/src/app/media/signage-media.service.ts b/apps/signage-manager/src/app/media/signage-media.service.ts index b6f38beb99d..498f0446401 100644 --- a/apps/signage-manager/src/app/media/signage-media.service.ts +++ b/apps/signage-manager/src/app/media/signage-media.service.ts @@ -22,11 +22,10 @@ import { UploadPermissions, UploadsService, } from '@placeos/common'; -import { loadAuthenticatedImage, openConfirmModal } from '@placeos/components'; +import { loadAuthenticatedImage } from '@placeos/components'; import { addSignageMedia, apiEndpoint, - del, post, type QueryResponse, querySignageMedia, @@ -40,15 +39,21 @@ import { updateSignageMedia, } from '@placeos/ts-client'; import type { ImageGenModalData } from '../image-gen/image-gen-modal.component'; +import { removeSignageUpload } from '../image-gen/image-gen.fn'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; import type { BulkMediaUploadItem, BulkMediaUploadModalData, } from '../shared/bulk-media-upload-modal.component'; +import { confirmThenRun } from '../shared/confirm-then-run'; import { decodeEntityNames } from '../shared/decode-entity-names.util'; import type { MediaEditChanges } from '../shared/media-edit-modal.component'; -import type { MediaTagModalResult } from '../shared/media-tag-modal.component'; +import type { + MediaTagModalData, + MediaTagModalResult, +} from '../shared/media-tag-modal.component'; import { PagedList } from '../shared/paged-list'; +import { storedSignal } from '../shared/stored-signal'; import { SignageContextService } from '../signage-context.service'; import { listSignageMediaTagCounts, @@ -65,6 +70,7 @@ import { } from '../signage-media-upload.util'; import { SignagePluginService } from '../signage-plugin.service'; import { dialogClosed, PAGE_SIZE, searchParam } from '../signage-service.util'; +import { uploadUrl } from '../signage-url.util'; import { convertImageToWebp, dataURLtoFile, @@ -129,9 +135,9 @@ function screenshotPageURL(url: string) { return page?.protocol === 'https:' ? page.href : ''; } -/** Absolute URL that serves an upload */ -function uploadUrl(upload_id: string) { - return `${location.origin}/api/engine/v2/uploads/${encodeURIComponent(upload_id)}/url`; +/** Absolute URL that serves an upload, to store in a media record */ +function absoluteUploadUrl(upload_id: string) { + return `${location.origin}${uploadUrl(upload_id)}`; } /** @@ -189,25 +195,6 @@ interface SignageUploadOptions { const SIGNAGE_VIEW_MODE_STORAGE_KEY = 'PlaceOS.SIGNAGE:media-view-mode:v1'; type MediaViewMode = 'grid' | 'list' | 'folder'; -function loadMediaViewMode(): MediaViewMode { - if (typeof localStorage === 'undefined') return 'grid'; - try { - const stored = localStorage.getItem(SIGNAGE_VIEW_MODE_STORAGE_KEY); - return stored === 'list' || stored === 'folder' ? stored : 'grid'; - } catch { - return 'grid'; - } -} - -function persistMediaViewMode(mode: MediaViewMode) { - if (typeof localStorage === 'undefined') return; - try { - localStorage.setItem(SIGNAGE_VIEW_MODE_STORAGE_KEY, mode); - } catch { - // Local storage can be unavailable in private browsing or restricted embeds. - } -} - /** * Signage media library: paging, tags, uploads and thumbnails, and * generated images @@ -237,8 +224,11 @@ export class SignageMediaService { ); public readonly search_term = signal(''); - public readonly media_view_mode = - signal(loadMediaViewMode()); + public readonly media_view_mode = storedSignal( + SIGNAGE_VIEW_MODE_STORAGE_KEY, + (stored) => + stored === 'list' || stored === 'folder' ? stored : 'grid', + ); // --- Media (paged incrementally as the user scrolls) --- // Searching is done by the backend so results are paged like the full @@ -596,7 +586,7 @@ export class SignageMediaService { orientation: 'landscape', ...media_item, media_id: upload_id, - media_uri: uploadUrl(upload_id), + media_uri: absoluteUploadUrl(upload_id), media_type: 'image', thumbnail_id, }); @@ -886,7 +876,7 @@ export class SignageMediaService { ...media_item, name: media_item.name || upload_file.name, media_id, - media_uri: uploadUrl(media_id), + media_uri: absoluteUploadUrl(media_id), media_type, orientation: is_landscape ? 'landscape' : 'portrait', thumbnail_id, @@ -983,50 +973,6 @@ export class SignageMediaService { } } - public async removeMedia(item: SignageMedia) { - if (!item?.id) return; - if ( - !this._context.requirePermission( - this._context.can_delete(), - 'SIGNAGE_MANAGER.SVC_NO_DELETE_MEDIA', - ) - ) - return; - const playlists = await this._playlistsUsingMedia([item.id]); - const result = await openConfirmModal( - { - title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_MEDIA_TITLE'), - content: this._withMediaUsage( - i18n('SIGNAGE_MANAGER.SVC_DELETE_NAMED_PLAIN', { - name: item.name, - }), - playlists, - ), - icon: { content: 'delete' }, - }, - this._dialog, - ); - if (result.reason !== 'done') return; - result.loading(i18n('SIGNAGE_MANAGER.SVC_MEDIA_REMOVING')); - try { - await removeSignageMedia( - item.id, - this._context.groupQueryParams({}), - ); - } catch { - result.close(); - notifyError(i18n('SIGNAGE_MANAGER.SVC_ERR_REMOVE_MEDIA')); - return; - } - this._removeMediaFromList([item.id]); - await this._removeDeletedMediaFromPlaylists( - [item.id], - playlists.map(({ id }) => id), - ); - notifySuccess(i18n('SIGNAGE_MANAGER.SVC_MEDIA_REMOVED')); - result.close(); - } - /** * Take deleted media out of the playlists that held it. The media is * already gone, so a failure here only warns. @@ -1045,9 +991,37 @@ export class SignageMediaService { } } + public async removeMedia(item: SignageMedia) { + if (!item?.id) return false; + return this._removeMediaItems( + [item], + i18n('SIGNAGE_MANAGER.SVC_DELETE_NAMED_PLAIN', { name: item.name }), + ); + } + public async removeMediaItems(items: SignageMedia[]) { const media_items = items.filter((item) => !!item?.id); if (!media_items.length) return false; + return this._removeMediaItems( + media_items, + i18n( + 'SIGNAGE_MANAGER.SVC_DELETE_SELECTED_MEDIA', + { count: media_items.length }, + media_items.length, + ), + ); + } + + /** + * Delete media after the user confirms, and take it out of the + * playlists that held it. + * @param content Text of the confirm modal, before the playlist usage + * @returns Whether every item was deleted + */ + private async _removeMediaItems( + media_items: SignageMedia[], + content: string, + ) { if ( !this._context.requirePermission( this._context.can_delete(), @@ -1057,42 +1031,44 @@ export class SignageMediaService { return false; const media_ids = media_items.map((item) => item.id); const playlists = await this._playlistsUsingMedia(media_ids); - const result = await openConfirmModal( + const removed = await confirmThenRun( + this._dialog, { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_MEDIA_TITLE'), content: this._withMediaUsage( - i18n( - 'SIGNAGE_MANAGER.SVC_DELETE_SELECTED_MEDIA', - { count: media_items.length }, - media_items.length, - ), + content, playlists, media_items.length, ), icon: { content: 'delete' }, }, - this._dialog, - ); - if (result.reason !== 'done') return false; - result.loading(i18n('SIGNAGE_MANAGER.SVC_MEDIA_REMOVING')); - const results = await settleEach(media_ids, (id) => - removeSignageMedia(id, this._context.groupQueryParams({})), - ); - const removed_ids = media_ids.filter( - (_, index) => results[index].status === 'fulfilled', + { + loading_key: 'SIGNAGE_MANAGER.SVC_MEDIA_REMOVING', + error_key: 'SIGNAGE_MANAGER.SVC_ERR_REMOVE_MEDIA', + action: async () => { + const results = await settleEach(media_ids, (id) => + removeSignageMedia( + id, + this._context.groupQueryParams({}), + ), + ); + const removed_ids = media_ids.filter( + (_, index) => results[index].status === 'fulfilled', + ); + if (removed_ids.length) { + this._removeMediaFromList(removed_ids); + await this._removeDeletedMediaFromPlaylists( + removed_ids, + playlists.map(({ id }) => id), + ); + } + if (removed_ids.length < media_ids.length) { + throw new Error('Some media was not deleted'); + } + }, + }, ); - if (removed_ids.length) { - this._removeMediaFromList(removed_ids); - await this._removeDeletedMediaFromPlaylists( - removed_ids, - playlists.map(({ id }) => id), - ); - } - result.close(); - if (removed_ids.length < media_ids.length) { - notifyError(i18n('SIGNAGE_MANAGER.SVC_ERR_REMOVE_MEDIA')); - return false; - } + if (!removed) return false; notifySuccess(i18n('SIGNAGE_MANAGER.SVC_MEDIA_REMOVED')); return true; } @@ -1163,84 +1139,86 @@ export class SignageMediaService { } public async renameMediaTag(tag: string, count: number) { - if (!tag) return false; + const result = await this._openMediaTagModal({ + action: 'rename', + tag, + count, + can_delete_media: false, + }); + if (result?.action !== 'rename') return false; + const group_id = this._context.api_group_id(); + return this._saveMediaTagChange( + () => + renameSignageMediaTag({ + current_tag: tag, + new_tag: result.new_tag, + ...(group_id ? { group_id } : {}), + }), + 'SIGNAGE_MANAGER.SVC_MEDIA_TAG_RENAMED', + ); + } + + public async removeMediaTag(tag: string, count: number) { + const result = await this._openMediaTagModal({ + action: 'remove', + tag, + count, + can_delete_media: this._context.can_delete_tagged_media(), + }); + if (result?.action !== 'remove') return false; if ( + result.remove_media && !this._context.requirePermission( - this._context.can_update_media_tags(), - 'SIGNAGE_MANAGER.SVC_NO_UPDATE_MEDIA', + this._context.can_delete_tagged_media(), + 'SIGNAGE_MANAGER.SVC_NO_DELETE_MEDIA', ) ) return false; - const { MediaTagModalComponent } = - await import('../shared/media-tag-modal.component'); - const ref = this._dialog.open(MediaTagModalComponent, { - data: { - action: 'rename', - tag, - count, - can_delete_media: false, - }, - width: 'min(28rem, calc(100vw - 2rem))', - }); - const result = await dialogClosed(ref); - if (result?.action !== 'rename') return false; - try { - const group_id = this._context.api_group_id(); - await renameSignageMediaTag({ - current_tag: tag, - new_tag: result.new_tag, - ...(group_id ? { group_id } : {}), - }); - } catch (error) { - notifyError( - i18n('SIGNAGE_MANAGER.SVC_MEDIA_TAG_ERROR', { - error: error instanceof Error ? error.message : `${error}`, + const group_id = this._context.api_group_id(); + return this._saveMediaTagChange( + () => + removeSignageMediaTag({ + tag, + ...(result.remove_media ? { remove_media: true } : {}), + ...(group_id ? { group_id } : {}), }), - ); - return false; - } - this._context.changed(); - notifySuccess(i18n('SIGNAGE_MANAGER.SVC_MEDIA_TAG_RENAMED')); - return true; + 'SIGNAGE_MANAGER.SVC_MEDIA_TAG_REMOVED', + ); } - public async removeMediaTag(tag: string, count: number) { - if (!tag) return false; + /** + * Ask how to change a media tag. Warns and gives undefined when the user + * cannot change tags. + */ + private async _openMediaTagModal(data: MediaTagModalData) { + if (!data.tag) return undefined; if ( !this._context.requirePermission( this._context.can_update_media_tags(), 'SIGNAGE_MANAGER.SVC_NO_UPDATE_MEDIA', ) ) - return false; + return undefined; const { MediaTagModalComponent } = await import('../shared/media-tag-modal.component'); const ref = this._dialog.open(MediaTagModalComponent, { - data: { - action: 'remove', - tag, - count, - can_delete_media: this._context.can_delete_tagged_media(), - }, + data, width: 'min(28rem, calc(100vw - 2rem))', }); - const result = await dialogClosed(ref); - if (result?.action !== 'remove') return false; - if ( - result.remove_media && - !this._context.requirePermission( - this._context.can_delete_tagged_media(), - 'SIGNAGE_MANAGER.SVC_NO_DELETE_MEDIA', - ) - ) - return false; + return dialogClosed(ref); + } + + /** + * Save a media tag change and reload the lists. Shows the error of the + * request when it fails. + * @returns Whether the change was saved + */ + private async _saveMediaTagChange( + request: () => Promise, + success_key: string, + ) { try { - const group_id = this._context.api_group_id(); - await removeSignageMediaTag({ - tag, - ...(result.remove_media ? { remove_media: true } : {}), - ...(group_id ? { group_id } : {}), - }); + await request(); } catch (error) { notifyError( i18n('SIGNAGE_MANAGER.SVC_MEDIA_TAG_ERROR', { @@ -1250,7 +1228,7 @@ export class SignageMediaService { return false; } this._context.changed(); - notifySuccess(i18n('SIGNAGE_MANAGER.SVC_MEDIA_TAG_REMOVED')); + notifySuccess(i18n(success_key)); return true; } @@ -1297,7 +1275,7 @@ export class SignageMediaService { /** Read an upload, with the auth the uploads route needs */ private async _fetchUpload(upload_id: string) { const source = await loadAuthenticatedImage( - uploadUrl(upload_id), + absoluteUploadUrl(upload_id), '/api/engine/v2/uploads', ); return (await fetch(source)).blob(); @@ -1337,9 +1315,7 @@ export class SignageMediaService { return ''; } finally { if (screenshot_id) { - del( - `${apiEndpoint()}/uploads/${encodeURIComponent(screenshot_id)}`, - ).catch(() => undefined); + removeSignageUpload(screenshot_id).catch(() => undefined); } } } @@ -1371,8 +1347,4 @@ export class SignageMediaService { ), }; } - - constructor() { - effect(() => persistMediaViewMode(this.media_view_mode())); - } } diff --git a/apps/signage-manager/src/app/playlists/playlist-item-details.component.ts b/apps/signage-manager/src/app/playlists/playlist-item-details.component.ts index b12b6d4b70f..d38bc21f134 100644 --- a/apps/signage-manager/src/app/playlists/playlist-item-details.component.ts +++ b/apps/signage-manager/src/app/playlists/playlist-item-details.component.ts @@ -10,19 +10,17 @@ import { MatRippleModule } from '@angular/material/core'; import { MatTabsModule } from '@angular/material/tabs'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; +import { LocaleService } from '@placeos/common'; import { IconComponent, MediaDurationPipe, TranslatePipe, } from '@placeos/components'; -import { - MediaAnimation, - PlaceSystem, - PlaceZone, - SignagePlaylist, -} from '@placeos/ts-client'; +import { PlaceSystem, PlaceZone, SignagePlaylist } from '@placeos/ts-client'; import { SignageDisplayService } from '../displays/signage-display.service'; import { playlistSchedules } from '../schedules/signage-schedule.util'; +import { animationLabel } from '../shared/media-labels.util'; +import { scheduleSummary } from '../shared/schedule-summary.util'; import { SignageSharedWithComponent } from '../shared/signage-shared-with.component'; import { SignageContextService } from '../signage-context.service'; import { SignageInventoryService } from '../signage-inventory.service'; @@ -30,7 +28,6 @@ import { playlistAnimation, playlistNextPlayLabels, playlistScheduleExpiryTooltip, - playlistScheduleLabel, } from '../signage-playlist.util'; import { SignageZoneService } from '../zones/signage-zone.service'; import { SignagePlaylistService } from './signage-playlist.service'; @@ -621,6 +618,7 @@ import { SignagePlaylistService } from './signage-playlist.service'; }) export class PlaylistItemDetailsComponent { private readonly _context = inject(SignageContextService); + private readonly _locale = inject(LocaleService); private readonly _display_service = inject(SignageDisplayService); private readonly _inventory_service = inject(SignageInventoryService); private readonly _playlist_service = inject(SignagePlaylistService); @@ -680,23 +678,7 @@ export class PlaylistItemDetailsComponent { public readonly animation_label = computed(() => { const pl = this.playlist(); - if (!pl) return 'COMMON.DEFAULT'; - switch (playlistAnimation(pl)) { - case MediaAnimation.Cut: - return 'SIGNAGE_MANAGER.ANIM_CUT'; - case MediaAnimation.CrossFade: - return 'SIGNAGE_MANAGER.ANIM_CROSS_FADE'; - case MediaAnimation.SlideTop: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_TOP'; - case MediaAnimation.SlideLeft: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_LEFT'; - case MediaAnimation.SlideRight: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_RIGHT'; - case MediaAnimation.SlideBottom: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_BOTTOM'; - default: - return 'COMMON.DEFAULT'; - } + return pl ? animationLabel(playlistAnimation(pl)) : 'COMMON.DEFAULT'; }); public readonly valid_from = computed(() => { @@ -715,7 +697,7 @@ export class PlaylistItemDetailsComponent { const pl = this.playlist(); if (!pl || pl.distribution) return []; return playlistSchedules(pl).map((schedule) => - playlistScheduleLabel(schedule), + scheduleSummary(schedule, this._locale.locale), ); }); diff --git a/apps/signage-manager/src/app/playlists/playlist-items.component.ts b/apps/signage-manager/src/app/playlists/playlist-items.component.ts index b1df79f6b25..3e633f84352 100644 --- a/apps/signage-manager/src/app/playlists/playlist-items.component.ts +++ b/apps/signage-manager/src/app/playlists/playlist-items.component.ts @@ -19,7 +19,7 @@ import { MatRippleModule } from '@angular/material/core'; import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { i18n } from '@placeos/common'; +import { i18n, LocaleService } from '@placeos/common'; import { IconComponent, LoadErrorComponent, @@ -33,12 +33,13 @@ import { } from '@placeos/ts-client'; import { MediaPreviewService } from '../media/media-preview.service'; import { MediaThumbnailComponent } from '../shared/media-thumbnail.component'; +import { scheduleSummary } from '../shared/schedule-summary.util'; +import { storedSignal } from '../shared/stored-signal'; import { SignageContextService } from '../signage-context.service'; import { playlistLoopDuration, playlistNextPlayTime, playlistScheduleExpiryTooltip, - playlistScheduleLabel, playlistScheduleNextPlayLabels, } from '../signage-playlist.util'; import { PlaylistActionsComponent } from './playlist-actions.component'; @@ -46,24 +47,6 @@ import { SignagePlaylistService } from './signage-playlist.service'; const NEXT_PLAY_SORT_STORAGE_KEY = 'PlaceOS.SIGNAGE:playlist-next-play-sort:v1'; -function loadNextPlaySort() { - if (typeof localStorage === 'undefined') return false; - try { - return localStorage.getItem(NEXT_PLAY_SORT_STORAGE_KEY) === 'true'; - } catch { - return false; - } -} - -function persistNextPlaySort(sorted: boolean) { - if (typeof localStorage === 'undefined') return; - try { - localStorage.setItem(NEXT_PLAY_SORT_STORAGE_KEY, `${sorted}`); - } catch { - // Local storage can be unavailable in private browsing or restricted embeds. - } -} - @Component({ selector: 'playlist-items', template: ` @@ -748,6 +731,7 @@ function persistNextPlaySort(sorted: boolean) { }) export class PlaylistItemsComponent { private readonly _context = inject(SignageContextService); + private readonly _locale = inject(LocaleService); private readonly _media_preview = inject(MediaPreviewService); private readonly _playlist_service = inject(SignagePlaylistService); private readonly _injector = inject(Injector); @@ -778,7 +762,10 @@ export class PlaylistItemsComponent { * Shows distribution items by when they next play. Only changes the view. * Kept in the browser, so distribution playlists open sorted once chosen. */ - public readonly sort_by_next_play = signal(loadNextPlaySort()); + public readonly sort_by_next_play = storedSignal( + NEXT_PLAY_SORT_STORAGE_KEY, + (stored) => stored === 'true', + ); /** * Time the next plays are counted from. Set when the sort is turned on * and when the playlist or its schedules change, so rows do not move @@ -897,10 +884,11 @@ export class PlaylistItemsComponent { public toggleNextPlaySort() { this.sort_by_next_play.update((sorted) => !sorted); - persistNextPlaySort(this.sort_by_next_play()); } - public scheduleLabel = playlistScheduleLabel; + public scheduleLabel(schedule: Partial) { + return scheduleSummary(schedule, this._locale.locale); + } /** Next plays tooltip of each item schedule. Updates when the schedules change. */ public readonly schedule_tooltips = computed( diff --git a/apps/signage-manager/src/app/playlists/signage-playlist.service.ts b/apps/signage-manager/src/app/playlists/signage-playlist.service.ts index 36bc34451ea..94396d344c2 100644 --- a/apps/signage-manager/src/app/playlists/signage-playlist.service.ts +++ b/apps/signage-manager/src/app/playlists/signage-playlist.service.ts @@ -23,7 +23,6 @@ import { listSignagePlaylistApprovers, listSignagePlaylistMedia, mediaThumbnail, - PlaceCurrentGroup, type QueryResponse, querySignagePlaylists, removeSignagePlaylist, @@ -49,10 +48,12 @@ import { hasTakeoverSchedule, type ScheduleItem, } from '../schedules/signage-schedule.util'; +import { confirmThenRun } from '../shared/confirm-then-run'; import { decodeEntityNames } from '../shared/decode-entity-names.util'; import { PagedList } from '../shared/paged-list'; import { byName } from '../shared/paged-search'; import type { PlaylistRequestApprovalModalResult } from '../shared/playlist-request-approval-modal.component'; +import { storedSignal } from '../shared/stored-signal'; import { SignageContextService } from '../signage-context.service'; import { type SignageInventory, @@ -98,30 +99,6 @@ function escapeHtml(text: string) { .replace(/"/g, '"'); } -function loadPlaylistMetaSessionCache(): Record { - if (typeof sessionStorage === 'undefined') return {}; - try { - const stored_value = sessionStorage.getItem(PLAYLIST_META_SESSION_KEY); - return stored_value ? JSON.parse(stored_value) : {}; - } catch { - return {}; - } -} - -function persistPlaylistMetaSessionCache( - cache: Record, -) { - if (typeof sessionStorage === 'undefined') return; - try { - sessionStorage.setItem( - PLAYLIST_META_SESSION_KEY, - JSON.stringify(cache), - ); - } catch { - // Session cache persistence is opportunistic only. - } -} - /** * Signage playlists, the selected playlist and its media, approvals and * takeover checks @@ -364,9 +341,18 @@ export class SignagePlaylistService { computation: () => null, }); - private readonly _playlist_meta_state = signal< + // Kept for the session, so list rows show thumbnails and approval state + // before their requests finish + private readonly _playlist_meta_state = storedSignal< Record - >(loadPlaylistMetaSessionCache()); + >( + PLAYLIST_META_SESSION_KEY, + (stored) => (stored ? JSON.parse(stored) : {}), + { + storage: 'session', + serialise: JSON.stringify, + }, + ); private readonly _playlist_meta_loading = signal>( {}, ); @@ -466,7 +452,8 @@ export class SignagePlaylistService { .sort(byName); } - private readonly _playlist_change = signal(Date.now()); + // A counter, so two changes in the same millisecond still reload + private readonly _playlist_change = signal(0); // Keyed by id, so a new copy of the selected playlist from a list reload // does not load the media again. @@ -597,7 +584,8 @@ export class SignagePlaylistService { ) ) return; - const result = await openConfirmModal( + const removed = await confirmThenRun( + this._dialog, { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_PLAYLIST_TITLE'), content: i18n('SIGNAGE_MANAGER.SVC_DELETE_NAMED', { @@ -605,16 +593,13 @@ export class SignagePlaylistService { }), icon: { content: 'delete' }, }, - this._dialog, + { + loading_key: 'SIGNAGE_MANAGER.SVC_REMOVING', + error_key: 'SIGNAGE_MANAGER.SVC_ERR_REMOVE_PLAYLIST', + action: () => removeSignagePlaylist(playlist.id), + }, ); - if (result.reason !== 'done') return; - try { - await removeSignagePlaylist(playlist.id); - } catch { - result.close(); - notifyError(i18n('SIGNAGE_MANAGER.SVC_ERR_REMOVE_PLAYLIST')); - return; - } + if (!removed) return; if (this.selected_playlist()?.id === playlist.id) { this.selected_playlist.set(null); this.selected_playlist_item.set(null); @@ -623,7 +608,6 @@ export class SignagePlaylistService { this._removePlaylistMediaState(playlist.id); this._context.changed(); notifySuccess(i18n('SIGNAGE_MANAGER.SVC_PLAYLIST_REMOVED')); - result.close(); } /** Whether a playlist is being duplicated */ @@ -734,58 +718,42 @@ export class SignagePlaylistService { public async requestPlaylistApproval(playlist: SignagePlaylist) { if (!playlist?.id) return; - if (this.playlist_approval_request_loading()) return; - if (this._context.can_approve()) { - await this.approvePlaylist(playlist); - return; - } - let approvers: SignagePlaylistApprover[] = []; - let group: PlaceCurrentGroup | null = null; - this.playlist_approval_request_loading.set(true); - try { - [group] = await this._context.groupsHolding( - playlist.id, - (group_id) => querySignagePlaylists({ group_id, limit: 500 }), - ); - if (!group) { - notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_GROUPS_FOR_PLAYLIST')); - return; - } - approvers = - ((await listSignagePlaylistApprovers( - group.group.id, - )) as SignagePlaylistApprover[]) || []; - } catch { - notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_APPROVERS')); - } finally { - this.playlist_approval_request_loading.set(false); - } - if (!group) return; - const { PlaylistRequestApprovalModalComponent } = - await import('../shared/playlist-request-approval-modal.component'); - const ref = this._dialog.open(PlaylistRequestApprovalModalComponent, { - data: { - playlist, - approvers, + await this._context.requestApproval({ + item_id: playlist.id, + loading: this.playlist_approval_request_loading, + approve: () => this.approvePlaylist(playlist), + query: (group_id) => + querySignagePlaylists({ group_id, limit: 500 }), + list_approvers: (group_id) => + listSignagePlaylistApprovers(group_id) as Promise< + SignagePlaylistApprover[] + >, + pick: async (approvers) => { + const { PlaylistRequestApprovalModalComponent } = + await import('../shared/playlist-request-approval-modal.component'); + const ref = this._dialog.open( + PlaylistRequestApprovalModalComponent, + { + data: { playlist, approvers }, + panelClass: 'mobile-fullscreen', + }, + ); + return dialogClosed(ref); }, - panelClass: 'mobile-fullscreen', + request: (group_id, message, approver_id) => + requestApprovalSignagePlaylist( + playlist.id, + group_id, + message, + approver_id, + ), + requested: () => + this.setPlaylistApprovalStatus(playlist.id, false, true), + no_groups_key: 'SIGNAGE_MANAGER.SVC_NO_GROUPS_FOR_PLAYLIST', + no_approvers_key: 'SIGNAGE_MANAGER.SVC_NO_APPROVERS', + error_key: 'SIGNAGE_MANAGER.SVC_ERR_REQUEST_APPROVAL', + success_key: 'SIGNAGE_MANAGER.SVC_APPROVAL_REQUESTED', }); - const result: PlaylistRequestApprovalModalResult | undefined = - await dialogClosed(ref); - if (!result) return; - try { - await requestApprovalSignagePlaylist( - playlist.id, - group.group.id, - result.message || '', - result.approver_id || '', - ); - } catch { - notifyError(i18n('SIGNAGE_MANAGER.SVC_ERR_REQUEST_APPROVAL')); - return; - } - this.setPlaylistApprovalStatus(playlist.id, false, true); - notifySuccess(i18n('SIGNAGE_MANAGER.SVC_APPROVAL_REQUESTED')); } public async removeMediaFromPlaylist( @@ -1000,17 +968,59 @@ export class SignagePlaylistService { }); const result = await dialogClosed(ref); if (result) { - this._playlist_change.set(Date.now()); + this._playlist_change.update((count) => count + 1); this._context.changed(); } return !!result; } + /** + * Put the media of a playlist back to an older version, after the user + * confirms. Used by the approval modals. + * @param previous_version Version of the media to restore + * @returns Whether the media was restored + */ + public async undoPlaylistChanges( + playlist: SignagePlaylist, + previous_version: SignagePlaylistMedia, + ) { + if ( + !this._context.requirePermission( + this._context.can_update(), + 'SIGNAGE_MANAGER.SVC_NO_UPDATE_PLAYLISTS', + ) + ) + return false; + const items = previous_version.items; + if (!items) return false; + const undone = await confirmThenRun( + this._dialog, + { + title: i18n('SIGNAGE_MANAGER.UNDO_CHANGES'), + content: i18n('SIGNAGE_MANAGER.PLAYLIST_REVERT_CONFIRM', { + name: playlist.name, + }), + confirm_text: i18n('SIGNAGE_MANAGER.UNDO_CHANGES'), + icon: { content: 'undo' }, + }, + { + loading_key: 'SIGNAGE_MANAGER.UNDOING_CHANGES', + error_key: 'SIGNAGE_MANAGER.PLAYLIST_REVERT_ERROR', + action: () => updateSignagePlaylistMedia(playlist.id, items), + }, + ); + if (!undone) return false; + this.setPlaylistApprovalStatus(playlist.id, false); + notifySuccess(i18n('SIGNAGE_MANAGER.PLAYLIST_REVERTED')); + this.refreshPlaylist(playlist.id); + return true; + } + public refreshPlaylist(playlist_id: string) { if (!playlist_id) return; this._removePlaylistMediaState(playlist_id); if (this.selected_playlist()?.id === playlist_id) { - this._playlist_change.set(Date.now()); + this._playlist_change.update((count) => count + 1); } this._context.changed(); } @@ -1048,7 +1058,7 @@ export class SignagePlaylistService { }); const result = await dialogClosed(ref); if (!result) return false; - this._playlist_change.set(Date.now()); + this._playlist_change.update((count) => count + 1); return true; } @@ -1070,7 +1080,7 @@ export class SignagePlaylistService { await updateSignagePlaylistMedia(playlist_id, list); this._setPlaylistMediaState(playlist_id, list, { approved: false }); notifySuccess(i18n('SIGNAGE_MANAGER.SVC_PLAYLIST_UPDATED')); - this._playlist_change.set(Date.now()); + this._playlist_change.update((count) => count + 1); } /** @@ -1380,7 +1390,7 @@ export class SignagePlaylistService { this.selected_playlist_item.set(null); this.selected_playlist_item_index.set(null); } - this._playlist_change.set(Date.now()); + this._playlist_change.update((count) => count + 1); } private _updatePlaylistMetaState( @@ -1388,12 +1398,7 @@ export class SignagePlaylistService { state: Record, ) => Record, ) { - let next_state: Record = {}; - this._playlist_meta_state.update((state) => { - next_state = updater(state); - return next_state; - }); - persistPlaylistMetaSessionCache(next_state); + this._playlist_meta_state.update(updater); } /** Playlist IDs with a boolean meta flag, mapped to its value */ diff --git a/apps/signage-manager/src/app/schedules/schedule-timeline.component.ts b/apps/signage-manager/src/app/schedules/schedule-timeline.component.ts index c1e653b120d..c8bf12335f3 100644 --- a/apps/signage-manager/src/app/schedules/schedule-timeline.component.ts +++ b/apps/signage-manager/src/app/schedules/schedule-timeline.component.ts @@ -1,15 +1,13 @@ -import { DatePipe } from '@angular/common'; import { Component, computed, inject, input, LOCALE_ID } from '@angular/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; import { i18n } from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; import { - DateFromPipe, - IconComponent, - TranslatePipe, -} from '@placeos/components'; -import { isSameDay } from 'date-fns'; -import { isDisplayOnline } from '../displays/display-status.util'; + displayLastSeen, + displayStatusKey, + isDisplayOnline, +} from '../displays/display-status.util'; import { MINUTES_PER_DAY, ScheduleTimelineRow, @@ -348,9 +346,7 @@ function dayPercent(minutes: number) { imports: [MatTooltipModule, RouterLink, IconComponent, TranslatePipe], }) export class ScheduleTimelineComponent { - private readonly _date_from = new DateFromPipe(); private readonly _locale = inject(LOCALE_ID); - private readonly _date = new DatePipe(this._locale); public readonly rows = input([]); public readonly view_tab = input<'displays' | 'zones'>('displays'); @@ -399,37 +395,14 @@ export class ScheduleTimelineComponent { if (this.view_tab() !== 'displays') return statuses; for (const row of this.rows()) { const online = isDisplayOnline(row.signage_last_seen, now); - const label = !row.signage_last_seen - ? i18n('SIGNAGE_MANAGER.DISPLAY_STATUS_NEVER_SEEN') - : i18n( - online - ? 'SIGNAGE_MANAGER.DISPLAY_STATUS_ONLINE' - : 'SIGNAGE_MANAGER.DISPLAY_STATUS_OFFLINE', - { - time: this._lastSeen( - row.signage_last_seen * 1000, - now, - ), - }, - ); + const label = i18n(displayStatusKey(row.signage_last_seen, now), { + time: displayLastSeen(row.signage_last_seen, now, this._locale), + }); statuses.set(row.id, { online, label }); } return statuses; }); - /** - * When a display last checked in: relative within the last hour, the - * time earlier today, and the date and time before today. Matches the - * display list. - */ - private _lastSeen(last_seen: number, now: number) { - if (now - last_seen < 60 * 60 * 1000) { - return this._date_from.transform(last_seen); - } - const date_format = isSameDay(last_seen, now) ? 'shortTime' : 'short'; - return this._date.transform(last_seen, date_format) || ''; - } - private _blockView( row: ScheduleTimelineRow, block: TimelineBlock, diff --git a/apps/signage-manager/src/app/schedules/signage-schedule.util.ts b/apps/signage-manager/src/app/schedules/signage-schedule.util.ts index d90ae56a61e..244b45442b0 100644 --- a/apps/signage-manager/src/app/schedules/signage-schedule.util.ts +++ b/apps/signage-manager/src/app/schedules/signage-schedule.util.ts @@ -13,9 +13,11 @@ import { isSameDay, startOfDay, } from 'date-fns'; +import { formatClockTime } from '../shared/schedule-summary.util'; import { createScheduleMaskFilter, playOnceStart, + playPeriodMinutes, } from '../signage-playlist.util'; const BLOCK_PALETTE = [ @@ -32,7 +34,6 @@ export const DAY_COUNT = 7; export const MINUTES_PER_DAY = 1440; /** Shortest block length that the timeline shows, so short plays stay visible */ const MIN_VISIBLE_MINUTES = 15; -const DEFAULT_PLAYLIST_DURATION = 24 * 60; export interface ScheduleBlock { playlist: SignagePlaylist; @@ -113,23 +114,11 @@ export function hasTakeoverSchedule(playlist: SignagePlaylist) { ); } -function playPeriodMinutes(schedule: Partial) { - return Number.isFinite(schedule.play_period) - ? Math.max(0, schedule.play_period || 0) - : DEFAULT_PLAYLIST_DURATION; -} - -function formatTime(minutes: number): string { - const hours = Math.floor(minutes / 60) % 24; - const mins = minutes % 60; - return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; -} - function formatTimeRange( start_minutes: number, duration_minutes: number, ): string { - return `${formatTime(start_minutes)} – ${formatTime(start_minutes + duration_minutes)}`; + return `${formatClockTime(start_minutes)} – ${formatClockTime(start_minutes + duration_minutes)}`; } /** Label and all day flag of a block. A length of 0 plays the playlist once. */ diff --git a/apps/signage-manager/src/app/shared/confirm-then-run.ts b/apps/signage-manager/src/app/shared/confirm-then-run.ts new file mode 100644 index 00000000000..28fbc34df6a --- /dev/null +++ b/apps/signage-manager/src/app/shared/confirm-then-run.ts @@ -0,0 +1,41 @@ +import { MatDialog } from '@angular/material/dialog'; +import { i18n, notifyError } from '@placeos/common'; +import { + type ConfirmModalData, + ConfirmModalComponent, + openConfirmModal, +} from '@placeos/components'; + +/** + * Ask the user to confirm, then run `action` while the confirm modal shows + * a loading message. The modal cannot be closed while `action` runs, as that + * would not stop the request, and it always closes when `action` ends. + * Shows the error of `error_key` when `action` throws. + * @returns Whether the user confirmed and `action` succeeded + */ +export async function confirmThenRun( + dialog: MatDialog, + confirm: ConfirmModalData, + options: { + loading_key: string; + error_key: string; + action: () => Promise; + }, +) { + const result = await openConfirmModal(confirm, dialog); + if (result.reason !== 'done') return false; + result.loading(i18n(options.loading_key)); + const confirm_ref = dialog.openDialogs?.at(-1); + if (confirm_ref?.componentInstance instanceof ConfirmModalComponent) { + confirm_ref.disableClose = true; + } + try { + await options.action(); + return true; + } catch { + notifyError(i18n(options.error_key)); + return false; + } finally { + result.close(); + } +} diff --git a/apps/signage-manager/src/app/shared/media-edit-modal.component.ts b/apps/signage-manager/src/app/shared/media-edit-modal.component.ts index 8b4c6ea6d00..a390ac863e4 100644 --- a/apps/signage-manager/src/app/shared/media-edit-modal.component.ts +++ b/apps/signage-manager/src/app/shared/media-edit-modal.component.ts @@ -1,7 +1,6 @@ import { Component, computed, - DestroyRef, effect, inject, OnDestroy, @@ -15,18 +14,13 @@ import { submit, validate, } from '@angular/forms/signals'; -import { - MAT_DIALOG_DATA, - MatDialog, - MatDialogRef, -} from '@angular/material/dialog'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatSliderModule } from '@angular/material/slider'; import { - HotkeysService, i18n, mediaAnimation, notifyError, @@ -71,6 +65,8 @@ import { normaliseWebPageUrl, webPageFrameUrl, } from '../signage-url.util'; +import { ANIMATION_OPTIONS } from './media-labels.util'; +import { saveHotkey } from './save-hotkey'; import { SignageSharedWithComponent } from './signage-shared-with.component'; /** Media fields the modal saves. `thumbnail_image` is a picked image as a @@ -106,33 +102,6 @@ export interface MediaEditModalData { preview: (item: SignageMedia) => void; } -/** Animations the user can pick, with their label keys */ -const ANIMATION_OPTIONS: { value: MediaAnimation; label: string }[] = [ - { value: MediaAnimation.Default, label: 'COMMON.DEFAULT' }, - { value: MediaAnimation.Cut, label: 'SIGNAGE_MANAGER.ANIM_CUT' }, - { - value: MediaAnimation.CrossFade, - label: 'SIGNAGE_MANAGER.ANIM_CROSS_FADE', - }, - { value: MediaAnimation.SlideTop, label: 'SIGNAGE_MANAGER.ANIM_SLIDE_TOP' }, - { - value: MediaAnimation.SlideLeft, - label: 'SIGNAGE_MANAGER.ANIM_SLIDE_LEFT', - }, - { - value: MediaAnimation.SlideRight, - label: 'SIGNAGE_MANAGER.ANIM_SLIDE_RIGHT', - }, - { - value: MediaAnimation.SlideBottom, - label: 'SIGNAGE_MANAGER.ANIM_SLIDE_BOTTOM', - }, -]; - -/** Focus targets where a plain key press belongs to the control, not a hotkey */ -const HOTKEY_BLOCKING_FOCUS = - 'select, mat-select, [role="combobox"], [role="listbox"], [role="option"], [role="menu"], [role="menuitem"]'; - interface MediaEditFormModel { name: string; media_uri: string; @@ -607,7 +576,6 @@ export class MediaEditModalComponent implements OnDestroy { private _data = inject(MAT_DIALOG_DATA); private _dialog_ref = inject>(MatDialogRef); - private readonly _dialog = inject(MatDialog); private readonly _schema_form = viewChild(SchemaFormComponent); @@ -744,10 +712,7 @@ export class MediaEditModalComponent implements OnDestroy { } constructor() { - const save_hotkey = inject(HotkeysService).listen(['KeyS'], () => { - if (this._canUseSaveHotkey()) this.saveMedia(); - }); - inject(DestroyRef).onDestroy(() => save_hotkey?.unsubscribe()); + saveHotkey(this._dialog_ref, () => this.saveMedia()); if (this.media_type === 'webpage') { this.preview_url.set( webPageFrameUrl(this.item.media_uri || this.item.media_url), @@ -786,16 +751,6 @@ export class MediaEditModalComponent implements OnDestroy { }); } - /** - * The save hotkey is a plain key, so it only acts while this modal is the - * top-most dialog and focus is not on a control that takes key presses. - */ - private _canUseSaveHotkey() { - const dialogs = this._dialog.openDialogs; - if (dialogs[dialogs.length - 1] !== this._dialog_ref) return false; - return !document.activeElement?.closest(HOTKEY_BLOCKING_FOCUS); - } - private _resolvePluginSchema(): Record | null { return ( pluginSchema(this.plugin_embed_schema()) || diff --git a/apps/signage-manager/src/app/shared/media-labels.util.ts b/apps/signage-manager/src/app/shared/media-labels.util.ts new file mode 100644 index 00000000000..26045d80bda --- /dev/null +++ b/apps/signage-manager/src/app/shared/media-labels.util.ts @@ -0,0 +1,46 @@ +import { MediaAnimation, type MediaType } from '@placeos/ts-client'; +import { type MediaTypeFilter } from '../media/media-view.util'; + +/** Label keys of the media types the library shows */ +export const MEDIA_TYPE_LABELS: Record = { + image: 'COMMON.IMAGE', + video: 'COMMON.VIDEO', + webpage: 'COMMON.WEBPAGE', + plugin: 'SIGNAGE_MANAGER.TYPE_PLUGIN', +}; + +/** Label key of a media type. Undefined for types the library does not show. */ +export function mediaTypeLabel(type: MediaType): string | undefined { + return MEDIA_TYPE_LABELS[type as MediaTypeFilter]; +} + +/** Animations the user can pick, with their label keys */ +export const ANIMATION_OPTIONS: { value: MediaAnimation; label: string }[] = [ + { value: MediaAnimation.Default, label: 'COMMON.DEFAULT' }, + { value: MediaAnimation.Cut, label: 'SIGNAGE_MANAGER.ANIM_CUT' }, + { + value: MediaAnimation.CrossFade, + label: 'SIGNAGE_MANAGER.ANIM_CROSS_FADE', + }, + { value: MediaAnimation.SlideTop, label: 'SIGNAGE_MANAGER.ANIM_SLIDE_TOP' }, + { + value: MediaAnimation.SlideLeft, + label: 'SIGNAGE_MANAGER.ANIM_SLIDE_LEFT', + }, + { + value: MediaAnimation.SlideRight, + label: 'SIGNAGE_MANAGER.ANIM_SLIDE_RIGHT', + }, + { + value: MediaAnimation.SlideBottom, + label: 'SIGNAGE_MANAGER.ANIM_SLIDE_BOTTOM', + }, +]; + +/** Label key of an animation. Unknown values show as the default. */ +export function animationLabel(animation: MediaAnimation) { + return ( + ANIMATION_OPTIONS.find(({ value }) => value === animation)?.label || + 'COMMON.DEFAULT' + ); +} diff --git a/apps/signage-manager/src/app/shared/media-preview-modal.component.ts b/apps/signage-manager/src/app/shared/media-preview-modal.component.ts index cf11424ffe6..2deea37c560 100644 --- a/apps/signage-manager/src/app/shared/media-preview-modal.component.ts +++ b/apps/signage-manager/src/app/shared/media-preview-modal.component.ts @@ -17,7 +17,6 @@ import { TranslatePipe, } from '@placeos/components'; import { - MediaAnimation, showSignageMedia, SignageMedia, SignagePlaylist, @@ -29,6 +28,7 @@ import { playlistMediaUrl, } from '../signage-playlist.util'; import { webPageFrameUrl } from '../signage-url.util'; +import { animationLabel, mediaTypeLabel } from './media-labels.util'; import { SignageSharedWithComponent } from './signage-shared-with.component'; interface MediaPreviewModalData { @@ -447,39 +447,13 @@ export class MediaPreviewModalComponent implements OnInit { }), ); - public readonly type_label = computed(() => { - switch (this.item.media_type) { - case 'video': - return 'COMMON.VIDEO'; - case 'image': - return 'COMMON.IMAGE'; - case 'webpage': - return 'COMMON.WEBPAGE'; - case 'plugin': - return 'SIGNAGE_MANAGER.TYPE_PLUGIN'; - default: - return this.item.media_type; - } - }); + public readonly type_label = computed( + () => mediaTypeLabel(this.item.media_type) ?? this.item.media_type, + ); - public readonly animation_label = computed(() => { - switch (mediaAnimation(this.item.animation)) { - case MediaAnimation.Cut: - return 'SIGNAGE_MANAGER.ANIM_CUT'; - case MediaAnimation.CrossFade: - return 'SIGNAGE_MANAGER.ANIM_CROSS_FADE'; - case MediaAnimation.SlideTop: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_TOP'; - case MediaAnimation.SlideLeft: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_LEFT'; - case MediaAnimation.SlideRight: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_RIGHT'; - case MediaAnimation.SlideBottom: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_BOTTOM'; - default: - return 'COMMON.DEFAULT'; - } - }); + public readonly animation_label = computed(() => + animationLabel(mediaAnimation(this.item.animation)), + ); public ngOnInit() { return this.loadPlaylists(); diff --git a/apps/signage-manager/src/app/shared/playlist-approve-modal.component.ts b/apps/signage-manager/src/app/shared/playlist-approve-modal.component.ts index ca6e4a77fed..3aefa2d9faa 100644 --- a/apps/signage-manager/src/app/shared/playlist-approve-modal.component.ts +++ b/apps/signage-manager/src/app/shared/playlist-approve-modal.component.ts @@ -13,7 +13,6 @@ import { approveSignagePlaylist, SignageMedia, SignagePlaylist, - updateSignagePlaylistMedia, } from '@placeos/ts-client'; import { MediaPreviewService } from '../media/media-preview.service'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; @@ -171,33 +170,15 @@ export class PlaylistApproveModalComponent { playlistMediaItems(playlist), ); + /** Restore the last approved version. The service asks the user to confirm. */ public async undoChanges() { - if (!this.can_update()) { - notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_UPDATE_PLAYLISTS')); - return; - } const [, previous_version] = this.playlist_versions(); - if (!previous_version?.items) return; - this.loading.set(i18n('SIGNAGE_MANAGER.UNDOING_CHANGES')); - this._dialog_ref.disableClose = true; - try { - await updateSignagePlaylistMedia( - this._data.playlist.id, - previous_version.items, - ); - this._playlist_service.setPlaylistApprovalStatus( - this._data.playlist.id, - false, - ); - notifySuccess(i18n('SIGNAGE_MANAGER.PLAYLIST_REVERTED')); - this._dialog_ref.close(true); - this._playlist_service.refreshPlaylist(this._data.playlist.id); - } catch { - notifyError(i18n('SIGNAGE_MANAGER.PLAYLIST_REVERT_ERROR')); - } finally { - this.loading.set(''); - this._dialog_ref.disableClose = false; - } + if (!previous_version) return; + const undone = await this._playlist_service.undoPlaylistChanges( + this._data.playlist, + previous_version, + ); + if (undone) this._dialog_ref.close(true); } /** diff --git a/apps/signage-manager/src/app/shared/playlist-assignments.ts b/apps/signage-manager/src/app/shared/playlist-assignments.ts new file mode 100644 index 00000000000..d96fd51ee9e --- /dev/null +++ b/apps/signage-manager/src/app/shared/playlist-assignments.ts @@ -0,0 +1,161 @@ +import { effect, untracked, type WritableSignal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { i18n, notifyError, notifySuccess } from '@placeos/common'; +import { type SignagePlaylist } from '@placeos/ts-client'; +import { type SignagePlaylistService } from '../playlists/signage-playlist.service'; +import { type SignageContextService } from '../signage-context.service'; +import { dialogClosed } from '../signage-service.util'; +import { decodeEntityNames } from './decode-entity-names.util'; + +/** Display or zone that holds a list of playlist IDs */ +interface PlaylistHolder { + id: string; + version: number; + playlists?: readonly string[]; +} + +/** Parts that differ between display and zone playlist assignment */ +export interface PlaylistAssignmentsOptions { + context: SignageContextService; + playlist_service: SignagePlaylistService; + dialog: MatDialog; + /** Saved copies of items, kept until the lists reload */ + overrides: WritableSignal>; + /** Selected item of the page that shows the items */ + selected: WritableSignal; + /** Field of the takeover check that holds the item ID */ + target: 'display_id' | 'zone_id'; + /** Translation key of the error when the playlist is already assigned */ + already_assigned_key: string; + /** Translation key of the error when adding a playlist fails */ + add_error_key: string; + /** Save new playlist IDs of an item */ + save_playlists: (item: T, playlists: string[]) => Promise; +} + +const ASSIGNMENT_ERROR = 'SIGNAGE_MANAGER.SVC_ASSIGNMENT_ERROR'; + +/** + * Adds playlists to displays or zones and takes them off. Keeps each saved + * item in `overrides` until the lists reload. Create it in an injection + * context, as it clears `overrides` when the group or data changes. + */ +export class PlaylistAssignments { + constructor(private readonly _options: PlaylistAssignmentsOptions) { + // Local edits only bridge the gap until the lists reload. Drop them + // when the group or data changes, so a stale copy never hides newer + // server data or sends an old version with the next patch. + effect(() => { + _options.context.api_group_id(); + _options.context.data_change(); + untracked(() => _options.overrides.set({})); + }); + } + + /** Whether the user can change assignments. Warns when not. */ + public canAssign() { + return this._options.context.requirePermission( + this._options.context.can_update(), + 'SIGNAGE_MANAGER.SVC_NO_UPDATE_ASSIGNMENTS', + ); + } + + /** Let the user pick a playlist. Undefined when the picker is closed. */ + public async pickPlaylist() { + const { PlaylistSelectModalComponent } = + await import('./playlist-select-modal.component'); + const ref = this._options.dialog.open(PlaylistSelectModalComponent, { + panelClass: 'mobile-fullscreen', + }); + return dialogClosed(ref); + } + + /** + * Keep a saved item until the lists reload. + * @returns The item with its names decoded, to use for selection + */ + public cache(item: T) { + const decoded = decodeEntityNames(item); + if (!decoded?.id) return decoded; + this._options.overrides.update((state) => ({ + ...state, + [decoded.id]: decoded, + })); + return decoded; + } + + /** Show a saved item in place of the selected item, when it is that item */ + public updateSelection(item: T | null) { + if (item && this._options.selected()?.id === item.id) { + this._options.selected.set(item); + } + } + + /** + * Add a playlist to an item, after a check for takeover conflicts. + * @param change Playlist to add, with its unsaved version when known + * @returns The saved item, or null when nothing was saved + */ + public async add( + item: T, + change: { playlist_id: string; playlist?: SignagePlaylist }, + success_key: string, + ) { + if (item.playlists?.includes(change.playlist_id)) { + notifyError(i18n(this._options.already_assigned_key)); + return null; + } + const confirmed = + await this._options.playlist_service.confirmTakeoverChange({ + ...change, + [this._options.target]: item.id, + }); + if (!confirmed) return null; + return this.save( + () => + this._options.save_playlists(item, [ + ...(item.playlists || []), + change.playlist_id, + ]), + success_key, + this._options.add_error_key, + ); + } + + /** + * Take a playlist off an item. + * @returns The saved item, or null when nothing was saved + */ + public async remove(item: T, playlist_id: string, success_key: string) { + if (!this.canAssign()) return null; + return this.save( + () => + this._options.save_playlists( + item, + (item.playlists || []).filter((id) => id !== playlist_id), + ), + success_key, + ); + } + + /** + * Run a save of an item and keep the result until the lists reload. + * @param error_key Translation key of the error to show when it fails + * @returns The saved item, or null after showing an error when it fails + */ + public async save( + request: () => Promise, + success_key: string, + error_key = ASSIGNMENT_ERROR, + ) { + const updated = await request().catch(() => null); + if (!updated) { + notifyError(i18n(error_key)); + return null; + } + const saved = this.cache(updated); + this._options.context.changed(); + notifySuccess(i18n(success_key)); + return saved; + } +} diff --git a/apps/signage-manager/src/app/shared/playlist-edit-modal.component.ts b/apps/signage-manager/src/app/shared/playlist-edit-modal.component.ts index f8f03e44441..aa31ee7fd55 100644 --- a/apps/signage-manager/src/app/shared/playlist-edit-modal.component.ts +++ b/apps/signage-manager/src/app/shared/playlist-edit-modal.component.ts @@ -1,4 +1,4 @@ -import { Component, DestroyRef, inject, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { applyEach, applyWhen, @@ -13,12 +13,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatSliderModule } from '@angular/material/slider'; -import { - HotkeysService, - i18n, - notifyError, - notifySuccess, -} from '@placeos/common'; +import { i18n, notifyError, notifySuccess } from '@placeos/common'; import { FullscreenModalShellComponent, MediaDurationPipe, @@ -29,6 +24,7 @@ import { DateFieldComponent } from '@placeos/form-fields'; import { MediaAnimation, SignagePlaylist } from '@placeos/ts-client'; import { endOfDay, getUnixTime, startOfDay } from 'date-fns'; import { playlistAnimation } from '../signage-playlist.util'; +import { ANIMATION_OPTIONS } from './media-labels.util'; import { createPlaylistScheduleModel, PlaylistScheduleFormComponent, @@ -36,7 +32,9 @@ import { playlistSchedulePayload, playlistSchedules, playlistScheduleSchema, + ScheduleList, } from './playlist-schedule-form.component'; +import { saveHotkey } from './save-hotkey'; import { SignageSharedWithComponent } from './signage-shared-with.component'; export interface PlaylistEditModalData { @@ -54,33 +52,6 @@ export interface PlaylistEditModalData { beforeSave?: (data: Partial) => Promise; } -/** Focus targets where a plain key press belongs to the control, not a hotkey */ -const HOTKEY_BLOCKING_FOCUS = - 'select, mat-select, [role="combobox"], [role="listbox"], [role="option"], [role="menu"], [role="menuitem"]'; - -/** Animations the user can pick, with their label keys */ -const ANIMATION_OPTIONS: { value: MediaAnimation; label: string }[] = [ - { value: MediaAnimation.Default, label: 'COMMON.DEFAULT' }, - { value: MediaAnimation.Cut, label: 'SIGNAGE_MANAGER.ANIM_CUT' }, - { - value: MediaAnimation.CrossFade, - label: 'SIGNAGE_MANAGER.ANIM_CROSS_FADE', - }, - { value: MediaAnimation.SlideTop, label: 'SIGNAGE_MANAGER.ANIM_SLIDE_TOP' }, - { - value: MediaAnimation.SlideLeft, - label: 'SIGNAGE_MANAGER.ANIM_SLIDE_LEFT', - }, - { - value: MediaAnimation.SlideRight, - label: 'SIGNAGE_MANAGER.ANIM_SLIDE_RIGHT', - }, - { - value: MediaAnimation.SlideBottom, - label: 'SIGNAGE_MANAGER.ANIM_SLIDE_BOTTOM', - }, -]; - export interface PlaylistEditFormModel { name: string; description: string; @@ -303,16 +274,18 @@ export interface PlaylistEditFormModel { } @@ -351,7 +324,6 @@ export class PlaylistEditModalComponent { public readonly loading = signal(false); public readonly animation_options = ANIMATION_OPTIONS; - public readonly active_schedule_index = signal(0); public readonly playlist = this._data.playlist; public readonly group_id = this._data.group_id || ''; /** Default animation when the modal opened */ @@ -375,6 +347,7 @@ export class PlaylistEditModalComponent { ? this.playlist.valid_until * 1000 : null, }); + public readonly schedule_list = new ScheduleList(this.model); public readonly form = form(this.model, (path) => { required(path.name); minLength(path.schedules, 1); @@ -388,53 +361,12 @@ export class PlaylistEditModalComponent { }); constructor() { - // The save hotkey is a plain key, so a select or list that has focus - // keeps it, e.g. to jump to an option such as "Square" - const save_hotkey = inject(HotkeysService).listen(['KeyS'], () => { - if (!document.activeElement?.closest(HOTKEY_BLOCKING_FOCUS)) { - this.savePlaylist(); - } - }); - inject(DestroyRef).onDestroy(() => save_hotkey?.unsubscribe()); + saveHotkey(this._dialog_ref, () => this.savePlaylist()); if (!this.model().distribution && !this.model().schedules.length) { - this.addSchedule(); + this.schedule_list.add(); } } - public addSchedule() { - this.model.update((model) => ({ - ...model, - schedules: [...model.schedules, createPlaylistScheduleModel()], - })); - this.active_schedule_index.set(this.model().schedules.length - 1); - } - - public removeSchedule(event: Event, index: number) { - event.preventDefault(); - event.stopPropagation(); - if (this.model().schedules.length <= 1) return; - this.model.update((model) => ({ - ...model, - schedules: model.schedules.filter((_, item_index) => { - return item_index !== index; - }), - })); - this.active_schedule_index.update((active_index) => { - if (active_index === index) return null; - return active_index > index ? active_index - 1 : active_index; - }); - } - - public openSchedule(index: number) { - this.active_schedule_index.update((active_index) => - active_index === index ? null : index, - ); - } - - public isScheduleOpen(index: number) { - return this.active_schedule_index() === index; - } - public async savePlaylist() { await submit(this.form, async () => { this.loading.set(true); diff --git a/apps/signage-manager/src/app/shared/playlist-item-schedule-modal.component.ts b/apps/signage-manager/src/app/shared/playlist-item-schedule-modal.component.ts index d33c8ae9801..94a6b8cc74b 100644 --- a/apps/signage-manager/src/app/shared/playlist-item-schedule-modal.component.ts +++ b/apps/signage-manager/src/app/shared/playlist-item-schedule-modal.component.ts @@ -19,6 +19,7 @@ import { PlaylistScheduleFormModel, playlistSchedulePayload, playlistScheduleSchema, + ScheduleList, } from './playlist-schedule-form.component'; export interface PlaylistItemScheduleModalData { @@ -81,16 +82,16 @@ export interface PlaylistItemScheduleModalData { } @@ -114,50 +115,18 @@ export class PlaylistItemScheduleModalComponent { /** Media the schedules are attached to, shown as context in the modal header */ public readonly media = new SignageMedia(this._data.item.media || {}); public readonly loading = signal(false); - public readonly active_schedule_index = signal(0); public readonly model = signal<{ schedules: PlaylistScheduleFormModel[] }>({ schedules: (this._data.item.schedules?.length ? this._data.item.schedules : [{}] ).map((schedule) => createPlaylistScheduleModel(schedule)), }); + public readonly schedule_list = new ScheduleList(this.model); public readonly form_model = form(this.model, (path) => { minLength(path.schedules, 1); applyEach(path.schedules, playlistScheduleSchema); }); - public addSchedule() { - this.model.update((model) => ({ - schedules: [...model.schedules, createPlaylistScheduleModel()], - })); - this.active_schedule_index.set(this.model().schedules.length - 1); - } - - public removeSchedule(event: Event, index: number) { - event.preventDefault(); - event.stopPropagation(); - if (this.model().schedules.length <= 1) return; - this.model.update((model) => ({ - schedules: model.schedules.filter( - (_, item_index) => item_index !== index, - ), - })); - this.active_schedule_index.update((active_index) => { - if (active_index === index) return null; - return active_index > index ? active_index - 1 : active_index; - }); - } - - public openSchedule(index: number) { - this.active_schedule_index.update((active_index) => - active_index === index ? null : index, - ); - } - - public isScheduleOpen(index: number) { - return this.active_schedule_index() === index; - } - public async saveSchedule() { await submit(this.form_model, async () => { this.loading.set(true); diff --git a/apps/signage-manager/src/app/shared/playlist-request-approval-modal.component.ts b/apps/signage-manager/src/app/shared/playlist-request-approval-modal.component.ts index aa997fd4878..872e3fc9e42 100644 --- a/apps/signage-manager/src/app/shared/playlist-request-approval-modal.component.ts +++ b/apps/signage-manager/src/app/shared/playlist-request-approval-modal.component.ts @@ -11,14 +11,13 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; -import { i18n, notifyError, notifySuccess, notifyWarn } from '@placeos/common'; +import { i18n, notifyError } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { SignageMedia, SignagePlaylist, type SignagePlaylistApprover, SignagePlaylistMedia, - updateSignagePlaylistMedia, } from '@placeos/ts-client'; import { MediaPreviewService } from '../media/media-preview.service'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; @@ -297,33 +296,15 @@ export class PlaylistRequestApprovalModalComponent { }); } + /** Restore the last approved version. The service asks the user to confirm. */ public async undoChanges() { - if (!this.can_update()) { - notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_UPDATE_PLAYLISTS')); - return; - } const [, previous_version] = await this._loadPlaylistVersions(); - if (!previous_version?.items) return; - this.loading.set(i18n('SIGNAGE_MANAGER.UNDOING_CHANGES')); - this._dialog_ref.disableClose = true; - try { - await updateSignagePlaylistMedia( - this.data.playlist.id, - previous_version.items, - ); - this._playlist_service.setPlaylistApprovalStatus( - this.data.playlist.id, - false, - ); - notifySuccess(i18n('SIGNAGE_MANAGER.PLAYLIST_REVERTED')); - this._dialog_ref.close(); - this._context.changed(); - } catch { - notifyError(i18n('SIGNAGE_MANAGER.PLAYLIST_REVERT_ERROR')); - } finally { - this.loading.set(''); - this._dialog_ref.disableClose = false; - } + if (!previous_version) return; + const undone = await this._playlist_service.undoPlaylistChanges( + this.data.playlist, + previous_version, + ); + if (undone) this._dialog_ref.close(); } public previewItem(item: SignageMedia) { diff --git a/apps/signage-manager/src/app/shared/playlist-schedule-form.component.ts b/apps/signage-manager/src/app/shared/playlist-schedule-form.component.ts index 82c5dfc7785..c91a3c503c8 100644 --- a/apps/signage-manager/src/app/shared/playlist-schedule-form.component.ts +++ b/apps/signage-manager/src/app/shared/playlist-schedule-form.component.ts @@ -8,6 +8,7 @@ import { input, output, signal, + type WritableSignal, } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FieldTree, FormField, schema, validate } from '@angular/forms/signals'; @@ -25,14 +26,10 @@ import { getTimezoneOffsetString, hasPlayableScheduleMask, i18n, - isCronMonthlyWeekday, isPlayOnceSchedule, isValidScheduleMask, LOCAL_TIMEZONE, LocaleService, - parseCronNumber, - parseCronWeekdays, - parseCronWeeksOfMonth, parsePlayAtLocal, setTimeInTimezone, settingSignal, @@ -58,20 +55,27 @@ import { formatPlayAtLocal, formatPlayDateTime, formatPlayDateTimeRange, - ordinal, - parseCronMonthDays, - playlistScheduleExpiryLabel, + playPeriodMinutes, } from '../signage-playlist.util'; +import { + DEFAULT_RECURRING_CRON, + DEFAULT_RECURRING_TIME, + formatClockTime, + normaliseMonthDays, + normaliseWeekdays, + normaliseWeeksOfMonth, + ordinalLabel, + parseRecurringCron, + playLengthLabel, + recurringScheduleSummary, + type RecurringScheduleType, + scheduleExpiryLabel, + scheduleMaskLabel, + WEEK_OF_MONTH_OPTIONS, + WEEKDAY_OPTIONS, +} from './schedule-summary.util'; export type PlaylistScheduleType = 'play_at' | 'play_cron'; -type RecurringScheduleType = - | 'hours' - | 'daily' - | 'weekdays' - | 'weekly' - | 'monthly' - | 'monthly_weekday' - | 'custom'; export interface PlaylistScheduleFormModel { schedule_type: PlaylistScheduleType; @@ -101,29 +105,15 @@ export interface PlaylistScheduleFormModel { const FULL_DAY_START_MINUTES = 0; const FULL_DAY_END_MINUTES = 23 * 60 + 59; -const DEFAULT_RECURRING_TIME = '00:00'; -const DEFAULT_RECURRING_CRON = '0 0 * * *'; -const WEEKDAY_OPTIONS = [ - { value: 1 }, - { value: 2 }, - { value: 3 }, - { value: 4 }, - { value: 5 }, - { value: 6 }, - { value: 0 }, -]; -const WEEK_OF_MONTH_OPTIONS = [1, 2, 3, 4, 5]; +/** Clock time of minutes from midnight, kept inside the day */ function minutesToTime(value: number) { - const safe_value = Math.max( - FULL_DAY_START_MINUTES, - Math.min(FULL_DAY_END_MINUTES, value || 0), + return formatClockTime( + Math.max( + FULL_DAY_START_MINUTES, + Math.min(FULL_DAY_END_MINUTES, value || 0), + ), ); - const hours = Math.floor(safe_value / 60) - .toString() - .padStart(2, '0'); - const minutes = (safe_value % 60).toString().padStart(2, '0'); - return `${hours}:${minutes}`; } function timeToMinutes(value: string) { @@ -134,41 +124,6 @@ function timeToMinutes(value: string) { ); } -function normaliseWeekdays(value: number[] | null | undefined) { - const seen_days = new Set(); - for (const day of value || []) { - if (day >= 0 && day <= 6) seen_days.add(day); - } - return WEEKDAY_OPTIONS.map((day) => day.value).filter((day) => - seen_days.has(day), - ); -} - -function normaliseMonthDays(value: number[] | null | undefined) { - const seen_days = new Set(); - for (const day of value || []) { - if (day >= 1 && day <= 31) seen_days.add(day); - } - return Array.from({ length: 31 }, (_, index) => index + 1).filter((day) => - seen_days.has(day), - ); -} - -function normaliseWeeksOfMonth(value: number[] | null | undefined) { - const seen_weeks = new Set(); - for (const week of value || []) { - if (week >= 1 && week <= 5) seen_weeks.add(week); - } - return WEEK_OF_MONTH_OPTIONS.filter((week) => seen_weeks.has(week)); -} - -function parseCronStep(value: string, min: number, max: number) { - const match = /^\*\/(\d+)$/.exec(value || ''); - if (!match) return null; - const step = +match[1]; - return step >= min && step <= max ? step : null; -} - function dayRangeForWeekOfMonth(value: number | null | undefined) { const week = Math.max(1, Math.min(5, value || 1)); if (week === 5) return '29-31'; @@ -176,79 +131,6 @@ function dayRangeForWeekOfMonth(value: number | null | undefined) { return `${start}-${start + 6}`; } -function parseRecurringCron(value: string | null | undefined) { - const [minute_part, hour_part, day_part, month_part, weekday_part] = ( - value || DEFAULT_RECURRING_CRON - ) - .trim() - .split(/\s+/); - const minute = parseCronNumber(minute_part, 0, 59); - const hour = parseCronNumber(hour_part, 0, 23); - const time = - minute === null || hour === null - ? DEFAULT_RECURRING_TIME - : `${hour.toString().padStart(2, '0')}:${minute - .toString() - .padStart(2, '0')}`; - const custom = { - recurrence_type: 'custom' as RecurringScheduleType, - recurrence_time: time, - recurrence_interval: 1, - recurrence_week_of_month: [1], - recurrence_weekdays: [1], - recurrence_day_of_month: [1], - }; - if (day_part === '*' && month_part === '*' && weekday_part === '*') { - const minute_step = - minute_part === '*' ? 1 : parseCronStep(minute_part, 1, 59); - if (minute_step !== null && hour_part === '*') return custom; - const hour_step = - hour_part === '*' ? 1 : parseCronStep(hour_part, 1, 23); - if (parseCronNumber(minute_part, 0, 59) === 0 && hour_step !== null) { - return { - ...custom, - recurrence_type: 'hours' as RecurringScheduleType, - recurrence_interval: hour_step, - }; - } - } - if (minute === null || hour === null || month_part !== '*') return custom; - if (day_part === '*' && weekday_part === '*') { - return { ...custom, recurrence_type: 'daily' as RecurringScheduleType }; - } - if (day_part === '*' && weekday_part === '1-5') { - return { - ...custom, - recurrence_type: 'weekdays' as RecurringScheduleType, - }; - } - const weekdays = normaliseWeekdays(parseCronWeekdays(weekday_part)); - if (isCronMonthlyWeekday(day_part, weekday_part)) { - return { - ...custom, - recurrence_type: 'monthly_weekday' as RecurringScheduleType, - recurrence_week_of_month: parseCronWeeksOfMonth(day_part), - recurrence_weekdays: weekdays, - }; - } - if (day_part === '*' && weekdays.length) { - return { - ...custom, - recurrence_type: 'weekly' as RecurringScheduleType, - recurrence_weekdays: weekdays, - }; - } - const days_of_month = parseCronMonthDays(day_part); - if (days_of_month?.length && weekday_part === '*') { - return { - ...custom, - recurrence_type: 'monthly' as RecurringScheduleType, - recurrence_day_of_month: days_of_month, - }; - } - return custom; -} - function isIntervalRecurringType( value: RecurringScheduleType | null | undefined, ) { @@ -304,12 +186,6 @@ function buildRecurringCron(value: { return `${minute} ${hour} * * *`; } -function playlistPlayPeriod(schedule: Partial) { - return Number.isFinite(schedule.play_period) - ? Math.max(0, schedule.play_period) - : DEFAULT_PLAY_PERIOD_MINUTES; -} - function scheduleTypeFor( schedule: Partial, ): PlaylistScheduleType { @@ -338,32 +214,6 @@ export function playlistSchedules(playlist: SignagePlaylist) { return playlist.schedules?.length ? playlist.schedules : [schedule]; } -function formatMinutes(value: number | null | undefined) { - const total_minutes = Math.max(0, Math.round(value || 0)); - if (!total_minutes) return ''; - const days = Math.floor(total_minutes / (24 * 60)); - const hours = Math.floor((total_minutes % (24 * 60)) / 60); - const minutes = total_minutes % 60; - const parts = [ - { - value: days, - key: 'SIGNAGE_MANAGER.DURATION_DAY', - }, - { - value: hours, - key: 'SIGNAGE_MANAGER.DURATION_HOUR', - }, - { - value: minutes, - key: 'SIGNAGE_MANAGER.DURATION_MINUTE', - }, - ]; - return parts - .filter((part) => part.value) - .map((part) => i18n(part.key, { count: part.value }, part.value)) - .join(' '); -} - /** Time ranges of the next five plays of a recurring schedule in the timezone. */ function nextCronPlayTimes( cron: string, @@ -444,6 +294,58 @@ function maskOccurrenceDates( return result; } +/** + * Schedules of a modal form, shown as an accordion with one schedule open + * at a time. A form keeps at least one schedule. + */ +export class ScheduleList { + /** Index of the open schedule, or null when all are closed */ + public readonly open_index = signal(0); + + constructor( + private readonly _model: WritableSignal<{ + schedules: PlaylistScheduleFormModel[]; + }>, + ) {} + + /** Add a new schedule and open it */ + public add() { + this._model.update((model) => ({ + ...model, + schedules: [...model.schedules, createPlaylistScheduleModel()], + })); + this.open_index.set(this._model().schedules.length - 1); + } + + /** Remove a schedule, unless it is the last one */ + public remove(event: Event, index: number) { + event.preventDefault(); + event.stopPropagation(); + if (this._model().schedules.length <= 1) return; + this._model.update((model) => ({ + ...model, + schedules: model.schedules.filter( + (_, item_index) => item_index !== index, + ), + })); + this.open_index.update((open_index) => { + if (open_index === index) return null; + return open_index > index ? open_index - 1 : open_index; + }); + } + + /** Open a schedule, or close it when it is open */ + public toggle(index: number) { + this.open_index.update((open_index) => + open_index === index ? null : index, + ); + } + + public isOpen(index: number) { + return this.open_index() === index; + } +} + export function createPlaylistScheduleModel( schedule?: Partial, ): PlaylistScheduleFormModel { @@ -467,7 +369,7 @@ export function createPlaylistScheduleModel( recurrence_week_of_month: recurring_schedule.recurrence_week_of_month, recurrence_weekdays: recurring_schedule.recurrence_weekdays, recurrence_day_of_month: recurring_schedule.recurrence_day_of_month, - play_period: playlistPlayPeriod(source), + play_period: playPeriodMinutes(source), has_mask: !!source.mask, mask: source.mask || '', has_valid_from: !!source.valid_from, @@ -1481,7 +1383,7 @@ export class PlaylistScheduleFormComponent { { length: 31 }, (_, index) => index + 1, ); - public readonly ordinal = ordinal; + public readonly ordinal = ordinalLabel; public readonly value = computed(() => this.schedule()().value()); public readonly mask_occurrence_dates = computed(() => maskOccurrenceDates(this.value(), this.timezone()), @@ -1642,78 +1544,28 @@ export class PlaylistScheduleFormComponent { public recurringScheduleSummary() { const value = this.value(); - if (value.recurrence_type === 'custom') { - return i18n('SIGNAGE_MANAGER.SUMMARY_ADVANCED'); - } - const interval = value.recurrence_interval || 1; - const start_time = this.formatPlayHour(value.play_start); - const period = value.play_period ?? DEFAULT_PLAY_PERIOD_MINUTES; - const duration = - formatMinutes(period) || i18n('SIGNAGE_MANAGER.ONE_PLAYLIST_PASS'); - if (value.recurrence_type === 'hours') { - return i18n( - 'SIGNAGE_MANAGER.SUMMARY_EVERY_HOUR', - { interval }, - interval, - ); - } - if (value.recurrence_type === 'weekdays') { - return i18n('SIGNAGE_MANAGER.SUMMARY_WEEKDAYS', { - time: start_time, - duration, - }); - } - if (value.recurrence_type === 'weekly') { - const days = this._weekdayNames(value.recurrence_weekdays); - return i18n('SIGNAGE_MANAGER.SUMMARY_WEEKLY', { - days, - time: start_time, - duration, - }); - } - if (value.recurrence_type === 'monthly') { - const days = normaliseMonthDays(value.recurrence_day_of_month); - return i18n('SIGNAGE_MANAGER.SUMMARY_MONTHLY', { - days: (days.length ? days : [1]) - .map((day) => ordinal(day)) - .join(', '), - time: start_time, - duration, - }); - } - if (value.recurrence_type === 'monthly_weekday') { - const weeks = normaliseWeeksOfMonth(value.recurrence_week_of_month); - return i18n('SIGNAGE_MANAGER.SUMMARY_MONTHLY_WEEKDAY', { - weeks: (weeks.length ? weeks : [1]) - .map((week) => ordinal(week)) - .join(', '), - days: this._weekdayNames(value.recurrence_weekdays), - time: start_time, - duration, - }); - } - return i18n('SIGNAGE_MANAGER.SUMMARY_DAILY', { - time: start_time, - duration, - }); + return ( + recurringScheduleSummary( + value, + this.formatPlayHour(value.play_start), + value.play_period ?? DEFAULT_PLAY_PERIOD_MINUTES, + this._locale.locale, + ) ?? i18n('SIGNAGE_MANAGER.SUMMARY_ADVANCED') + ); } public scheduleSummary() { const value = this.value(); const period = value.play_period ?? DEFAULT_PLAY_PERIOD_MINUTES; - const duration = - formatMinutes(period) || i18n('SIGNAGE_MANAGER.ONE_PLAYLIST_PASS'); const takeover = value.play_takeover ? i18n('SIGNAGE_MANAGER.TAKEOVER_SUFFIX') : ''; - const expiry = playlistScheduleExpiryLabel( - playlistSchedulePayload(value), + const expiry = scheduleExpiryLabel( + playlistSchedulePayload(value).valid_until, ); const expiry_suffix = (expiry ? ` · ${expiry}` : '') + - (value.has_mask - ? ` · ${i18n('SIGNAGE_MANAGER.SCHEDULE_MASK_SUMMARY', { mask: value.mask, size: value.mask.length })}` - : ''); + (value.has_mask ? ` · ${scheduleMaskLabel(value.mask)}` : ''); if (value.schedule_type === 'play_at') { const date = new Date(value.play_at || Date.now()); const datetime = value.play_at_exact @@ -1721,7 +1573,7 @@ export class PlaylistScheduleFormComponent { : `${formatPlayDateTime(date)} ${i18n('SIGNAGE_MANAGER.DISPLAY_LOCAL_TIME')}`; return `${i18n('SIGNAGE_MANAGER.SUMMARY_PLAY_ONCE', { datetime, - duration, + duration: playLengthLabel(period), })}${takeover}${expiry_suffix}`; } return `${this.recurringScheduleSummary()} · ${this.timezone()}${takeover}${expiry_suffix}`; @@ -1810,12 +1662,4 @@ export class PlaylistScheduleFormComponent { const date = new Date(2024, 0, 1 + monday_based_offset); return this._date_pipe.transform(date, format) || ''; } - - private _weekdayNames(value: number[] | null | undefined) { - const weekdays = normaliseWeekdays(value); - return (weekdays.length ? weekdays : [1]) - .map((day) => this.weekdayLabel(day, 'EEEE')) - .filter((_) => _) - .join(', '); - } } diff --git a/apps/signage-manager/src/app/shared/save-hotkey.ts b/apps/signage-manager/src/app/shared/save-hotkey.ts new file mode 100644 index 00000000000..29161572f47 --- /dev/null +++ b/apps/signage-manager/src/app/shared/save-hotkey.ts @@ -0,0 +1,26 @@ +import { DestroyRef, inject } from '@angular/core'; +import { MatDialog, type MatDialogRef } from '@angular/material/dialog'; +import { HotkeysService } from '@placeos/common'; + +/** Focus targets where a plain key press belongs to the control, not a hotkey */ +const HOTKEY_BLOCKING_FOCUS = + 'select, mat-select, [role="combobox"], [role="listbox"], [role="option"], [role="menu"], [role="menuitem"]'; + +/** + * Run `save` when the user presses S in a modal. S is a plain key, so it + * only acts while `dialog_ref` is the top-most dialog and focus is not on a + * control that takes key presses, such as a select that jumps to an option. + * Call it in an injection context. It stops when that context is destroyed. + */ +export function saveHotkey( + dialog_ref: MatDialogRef, + save: () => unknown, +) { + const dialog = inject(MatDialog); + const listener = inject(HotkeysService).listen(['KeyS'], () => { + if (dialog.openDialogs.at(-1) !== dialog_ref) return; + if (document.activeElement?.closest(HOTKEY_BLOCKING_FOCUS)) return; + save(); + }); + inject(DestroyRef).onDestroy(() => listener?.unsubscribe()); +} diff --git a/apps/signage-manager/src/app/shared/schedule-summary.util.ts b/apps/signage-manager/src/app/shared/schedule-summary.util.ts new file mode 100644 index 00000000000..f0d98814d95 --- /dev/null +++ b/apps/signage-manager/src/app/shared/schedule-summary.util.ts @@ -0,0 +1,386 @@ +import { + i18n, + isCronMonthlyWeekday, + isPlayOnceSchedule, + parseCronNumber, + parseCronWeekdays, + parseCronWeeksOfMonth, + settingSignal, +} from '@placeos/common'; +import { type SignagePlaylistSchedule } from '@placeos/ts-client'; +import { format, fromUnixTime } from 'date-fns'; +import { + formatPlayDateTime, + playOnceStart, + playPeriodMinutes, +} from '../signage-playlist.util'; + +export type RecurringScheduleType = + | 'hours' + | 'daily' + | 'weekdays' + | 'weekly' + | 'monthly' + | 'monthly_weekday' + | 'custom'; + +/** Repeat pattern of a recurring schedule, as the schedule form edits it */ +export interface RecurringSchedule { + recurrence_type: RecurringScheduleType; + /** Start time as `HH:mm` */ + recurrence_time: string; + recurrence_interval: number; + recurrence_week_of_month: number[]; + recurrence_weekdays: number[]; + recurrence_day_of_month: number[]; +} + +export const DEFAULT_RECURRING_TIME = '00:00'; +export const DEFAULT_RECURRING_CRON = '0 0 * * *'; +/** Weekdays in the order the form shows them, Monday first */ +export const WEEKDAY_OPTIONS = [1, 2, 3, 4, 5, 6, 0].map((value) => ({ + value, +})); +export const WEEK_OF_MONTH_OPTIONS = [1, 2, 3, 4, 5]; + +/** + * Clock time of minutes from midnight as `HH:mm`. Minutes past the end of + * the day wrap to the next day. + */ +export function formatClockTime(minutes: number) { + const hours = Math.floor(minutes / 60) % 24; + const mins = minutes % 60; + return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; +} + +/** + * Clock time such as `09:30` in the time format of the app: 24 hour when + * the `use_24_hour_time` setting is on, otherwise 12 hour such as `9:30 AM`. + * Matches `SettingsService.time_format_signal`. + * @param time Time as `HH:mm` + */ +export function formatSettingTime(time: string) { + const [hours, minutes] = time.split(':').map((part) => +part || 0); + return format( + new Date(2000, 0, 1, hours, minutes), + settingSignal('use_24_hour_time', false)() ? 'HH:mm' : 'h:mm a', + ); +} + +/** + * Minutes between plays of a cron that plays every N minutes, such as + * `*\/5 * * * *`. Null for other crons. + */ +function cronMinuteInterval(cron: string) { + const [minute, ...rest] = cron.trim().split(/\s+/); + if (rest.length !== 4 || rest.some((part) => part !== '*')) return null; + return minute === '*' ? 1 : parseCronStep(minute, 1, 59); +} + +/** Valid weekdays with no repeats, Monday first */ +export function normaliseWeekdays(value: number[] | null | undefined) { + const seen_days = new Set(); + for (const day of value || []) { + if (day >= 0 && day <= 6) seen_days.add(day); + } + return WEEKDAY_OPTIONS.map((day) => day.value).filter((day) => + seen_days.has(day), + ); +} + +/** Valid days of the month with no repeats, in order */ +export function normaliseMonthDays(value: number[] | null | undefined) { + const seen_days = new Set(); + for (const day of value || []) { + if (day >= 1 && day <= 31) seen_days.add(day); + } + return Array.from({ length: 31 }, (_, index) => index + 1).filter((day) => + seen_days.has(day), + ); +} + +/** Valid weeks of the month with no repeats, in order */ +export function normaliseWeeksOfMonth(value: number[] | null | undefined) { + const seen_weeks = new Set(); + for (const week of value || []) { + if (week >= 1 && week <= 5) seen_weeks.add(week); + } + return WEEK_OF_MONTH_OPTIONS.filter((week) => seen_weeks.has(week)); +} + +/** Days of the month in a plain list such as "1,15". Empty for other values. */ +function parseCronMonthDays(value: string) { + if (!value || value === '*') return []; + const days = value.split(',').map((part) => parseCronNumber(part, 1, 31)); + return days.every((day) => day !== null) + ? [...new Set(days)].sort((a, b) => a - b) + : []; +} + +function parseCronStep(value: string, min: number, max: number) { + const match = /^\*\/(\d+)$/.exec(value || ''); + if (!match) return null; + const step = +match[1]; + return step >= min && step <= max ? step : null; +} + +/** + * Repeat pattern of a cron expression. Expressions the form cannot edit + * give the `custom` type. + */ +export function parseRecurringCron( + value: string | null | undefined, +): RecurringSchedule { + const [minute_part, hour_part, day_part, month_part, weekday_part] = ( + value || DEFAULT_RECURRING_CRON + ) + .trim() + .split(/\s+/); + const minute = parseCronNumber(minute_part, 0, 59); + const hour = parseCronNumber(hour_part, 0, 23); + const time = + minute === null || hour === null + ? DEFAULT_RECURRING_TIME + : formatClockTime(hour * 60 + minute); + const custom: RecurringSchedule = { + recurrence_type: 'custom', + recurrence_time: time, + recurrence_interval: 1, + recurrence_week_of_month: [1], + recurrence_weekdays: [1], + recurrence_day_of_month: [1], + }; + if (day_part === '*' && month_part === '*' && weekday_part === '*') { + const minute_step = + minute_part === '*' ? 1 : parseCronStep(minute_part, 1, 59); + if (minute_step !== null && hour_part === '*') return custom; + const hour_step = + hour_part === '*' ? 1 : parseCronStep(hour_part, 1, 23); + if (parseCronNumber(minute_part, 0, 59) === 0 && hour_step !== null) { + return { + ...custom, + recurrence_type: 'hours', + recurrence_interval: hour_step, + }; + } + } + if (minute === null || hour === null || month_part !== '*') return custom; + if (day_part === '*' && weekday_part === '*') { + return { ...custom, recurrence_type: 'daily' }; + } + if (day_part === '*' && weekday_part === '1-5') { + return { ...custom, recurrence_type: 'weekdays' }; + } + const weekdays = normaliseWeekdays(parseCronWeekdays(weekday_part)); + if (isCronMonthlyWeekday(day_part, weekday_part)) { + return { + ...custom, + recurrence_type: 'monthly_weekday', + recurrence_week_of_month: parseCronWeeksOfMonth(day_part), + recurrence_weekdays: weekdays, + }; + } + if (day_part === '*' && weekdays.length) { + return { + ...custom, + recurrence_type: 'weekly', + recurrence_weekdays: weekdays, + }; + } + const days_of_month = parseCronMonthDays(day_part); + if (days_of_month.length && weekday_part === '*') { + return { + ...custom, + recurrence_type: 'monthly', + recurrence_day_of_month: days_of_month, + }; + } + return custom; +} + +/** Translated ordinal of a day or week of the month, such as "2nd" */ +export function ordinalLabel(value: number) { + return i18n('SIGNAGE_MANAGER.ORDINAL', { count: value }, value); +} + +/** Translated length such as "1 hour 30 minutes". Empty for 0. */ +export function formatDuration(value: number | null | undefined) { + const total_minutes = Math.max(0, Math.round(value || 0)); + if (!total_minutes) return ''; + const parts = [ + { + value: Math.floor(total_minutes / (24 * 60)), + key: 'SIGNAGE_MANAGER.DURATION_DAY', + }, + { + value: Math.floor((total_minutes % (24 * 60)) / 60), + key: 'SIGNAGE_MANAGER.DURATION_HOUR', + }, + { value: total_minutes % 60, key: 'SIGNAGE_MANAGER.DURATION_MINUTE' }, + ]; + return parts + .filter((part) => part.value) + .map((part) => i18n(part.key, { count: part.value }, part.value)) + .join(' '); +} + +/** Translated play length. A length of 0 plays the playlist once. */ +export function playLengthLabel(minutes: number) { + return formatDuration(minutes) || i18n('SIGNAGE_MANAGER.ONE_PLAYLIST_PASS'); +} + +/** Names of weekdays in the locale, Monday first, such as "Monday, Friday" */ +function weekdayNames(value: number[], locale: string) { + const format = new Intl.DateTimeFormat(locale, { weekday: 'long' }); + const weekdays = normaliseWeekdays(value); + // 1 January 2024 is a Monday + return (weekdays.length ? weekdays : [1]) + .map((day) => format.format(new Date(2024, 0, day === 0 ? 7 : day))) + .join(', '); +} + +/** + * Translated summary of a repeat pattern, such as "Starts every weekday at + * 09:00 for 1 hour.". Null for a `custom` pattern, which callers describe. + * @param time Start time to show + * @param period Play length in minutes + * @param locale Locale of the weekday names + */ +export function recurringScheduleSummary( + value: Omit, + time: string, + period: number, + locale: string, +) { + const duration = playLengthLabel(period); + switch (value.recurrence_type) { + case 'custom': + return null; + case 'hours': { + const interval = value.recurrence_interval || 1; + return i18n( + 'SIGNAGE_MANAGER.SUMMARY_EVERY_HOUR', + { interval }, + interval, + ); + } + case 'weekdays': + return i18n('SIGNAGE_MANAGER.SUMMARY_WEEKDAYS', { time, duration }); + case 'weekly': + return i18n('SIGNAGE_MANAGER.SUMMARY_WEEKLY', { + days: weekdayNames(value.recurrence_weekdays, locale), + time, + duration, + }); + case 'monthly': { + const days = normaliseMonthDays(value.recurrence_day_of_month); + return i18n('SIGNAGE_MANAGER.SUMMARY_MONTHLY', { + days: (days.length ? days : [1]).map(ordinalLabel).join(', '), + time, + duration, + }); + } + case 'monthly_weekday': { + const weeks = normaliseWeeksOfMonth(value.recurrence_week_of_month); + return i18n('SIGNAGE_MANAGER.SUMMARY_MONTHLY_WEEKDAY', { + weeks: (weeks.length ? weeks : [1]) + .map(ordinalLabel) + .join(', '), + days: weekdayNames(value.recurrence_weekdays, locale), + time, + duration, + }); + } + default: + return i18n('SIGNAGE_MANAGER.SUMMARY_DAILY', { time, duration }); + } +} + +/** Translated distance in its largest whole unit, such as "3 days" */ +function distanceLabel(milliseconds: number) { + const minutes = Math.max(1, Math.round(milliseconds / 60_000)); + const [value, key] = + minutes >= 24 * 60 + ? [Math.round(minutes / (24 * 60)), 'SIGNAGE_MANAGER.DURATION_DAY'] + : minutes >= 60 + ? [Math.round(minutes / 60), 'SIGNAGE_MANAGER.DURATION_HOUR'] + : [minutes, 'SIGNAGE_MANAGER.DURATION_MINUTE']; + return i18n(key, { count: value }, value); +} + +/** + * Translated end of a schedule relative to now, such as "until 3 days from + * now". Empty when the schedule has no end. + * @param valid_until End of the schedule in Unix seconds + */ +export function scheduleExpiryLabel( + valid_until: number | undefined, + now = Date.now(), +) { + if (!valid_until) return ''; + const expiry = fromUnixTime(valid_until).getTime(); + const time = distanceLabel(Math.abs(expiry - now)); + return expiry >= now + ? i18n('SIGNAGE_MANAGER.SCHEDULE_UNTIL_FROM_NOW', { time }) + : i18n('SIGNAGE_MANAGER.SCHEDULE_UNTIL_AGO', { time }); +} + +/** Translated summary of a schedule mask */ +export function scheduleMaskLabel(mask: string) { + return i18n('SIGNAGE_MANAGER.SCHEDULE_MASK_SUMMARY', { + mask, + size: mask.length, + }); +} + +/** + * Translated one line summary of a saved playlist schedule, for lists. + * @param locale Locale of the weekday names + */ +export function scheduleSummary( + schedule: Partial, + locale: string, +) { + const period = playPeriodMinutes(schedule); + const takeover = schedule.play_takeover + ? i18n('SIGNAGE_MANAGER.TAKEOVER_SUFFIX') + : ''; + const suffix = [ + scheduleExpiryLabel(schedule.valid_until), + schedule.mask ? scheduleMaskLabel(schedule.mask) : '', + ] + .filter((part) => part) + .map((part) => ` · ${part}`) + .join(''); + if (isPlayOnceSchedule(schedule)) { + const start = playOnceStart(schedule); + // Follow the app's time format, like the recurring summaries + const hour12 = !settingSignal('use_24_hour_time', false)(); + const date = start ? formatPlayDateTime(start, undefined, hour12) : ''; + const datetime = schedule.play_at + ? date + : `${date} ${i18n('SIGNAGE_MANAGER.DISPLAY_LOCAL_TIME')}`; + return `${i18n('SIGNAGE_MANAGER.SUMMARY_PLAY_ONCE', { + datetime, + duration: playLengthLabel(period), + })}${takeover}${suffix}`; + } + const cron = schedule.play_cron || DEFAULT_RECURRING_CRON; + const recurrence = parseRecurringCron(cron); + // The form has no minute repeat, so its parser reads one as custom + const minute_interval = cronMinuteInterval(cron); + const summary = + minute_interval !== null + ? i18n( + 'SIGNAGE_MANAGER.SUMMARY_EVERY_MINUTE', + { interval: minute_interval }, + minute_interval, + ) + : (recurringScheduleSummary( + recurrence, + formatSettingTime(recurrence.recurrence_time), + period, + locale, + ) ?? `${i18n('SIGNAGE_MANAGER.CUSTOM_SCHEDULE')} (${cron})`); + return `${summary}${takeover}${suffix}`; +} diff --git a/apps/signage-manager/src/app/shared/stored-signal.ts b/apps/signage-manager/src/app/shared/stored-signal.ts new file mode 100644 index 00000000000..37d2750aeda --- /dev/null +++ b/apps/signage-manager/src/app/shared/stored-signal.ts @@ -0,0 +1,51 @@ +import { signal, untracked, type WritableSignal } from '@angular/core'; + +/** Browser storage, or null when the page cannot use it */ +function storageArea(type: 'local' | 'session') { + try { + return (type === 'session' ? sessionStorage : localStorage) ?? null; + } catch { + // Storage can be unavailable in private browsing or restricted embeds + return null; + } +} + +/** + * Writable signal that starts from a stored value and stores each new value + * at once. When storage is unavailable, the value is kept in memory only. + * @param parse Value of the stored text, or of null when nothing is stored + * or the text cannot be read + * @param options.serialise Text to store. Null removes the key. + */ +export function storedSignal( + key: string, + parse: (stored: string | null) => T, + options: { + storage?: 'local' | 'session'; + serialise?: (value: T) => string | null; + } = {}, +): WritableSignal { + const type = options.storage || 'local'; + const serialise = options.serialise || String; + let initial: T; + try { + initial = parse(storageArea(type)?.getItem(key) ?? null); + } catch { + initial = parse(null); + } + const value = signal(initial); + const set = value.set; + value.set = (next: T) => { + set(next); + try { + const text = serialise(next); + const area = storageArea(type); + if (text === null) area?.removeItem(key); + else area?.setItem(key, text); + } catch { + // Storing is best effort. The value stays in memory. + } + }; + value.update = (fn: (current: T) => T) => value.set(fn(untracked(value))); + return value; +} diff --git a/apps/signage-manager/src/app/shared/template-edit-modal.component.ts b/apps/signage-manager/src/app/shared/template-edit-modal.component.ts index fd3eed73711..31c336533c2 100644 --- a/apps/signage-manager/src/app/shared/template-edit-modal.component.ts +++ b/apps/signage-manager/src/app/shared/template-edit-modal.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, DestroyRef, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { form, FormField, required, submit } from '@angular/forms/signals'; import { MatRippleModule } from '@angular/material/core'; import { @@ -9,7 +9,6 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { - HotkeysService, i18n, notifyError, notifySuccess, @@ -32,6 +31,7 @@ import { MediaSelectModalComponent, MediaSelectModalData, } from './media-select-modal.component'; +import { saveHotkey } from './save-hotkey'; import { SignageSharedWithComponent } from './signage-shared-with.component'; export interface TemplateEditModalData { @@ -253,10 +253,7 @@ export class TemplateEditModalComponent { }); constructor() { - const save_hotkey = inject(HotkeysService).listen(['KeyS'], () => { - if (this._canUseSaveHotkey()) this.saveTemplate(); - }); - inject(DestroyRef).onDestroy(() => save_hotkey?.unsubscribe()); + saveHotkey(this._dialog_ref, () => this.saveTemplate()); } public async selectBackground() { @@ -315,15 +312,4 @@ export class TemplateEditModalComponent { } }); } - - /** - * Whether the "S" hotkey may save. Ignored while another dialog (e.g. the - * background picker) is on top, or while focus is in a select or list. - */ - private _canUseSaveHotkey() { - if (this._dialog.openDialogs.at(-1) !== this._dialog_ref) return false; - return !document.activeElement?.closest( - 'select, [role="combobox"], [role="listbox"], [role="option"]', - ); - } } diff --git a/apps/signage-manager/src/app/shared/template-mappings.component.ts b/apps/signage-manager/src/app/shared/template-mappings.component.ts index b9d8e549051..43fe194bfbc 100644 --- a/apps/signage-manager/src/app/shared/template-mappings.component.ts +++ b/apps/signage-manager/src/app/shared/template-mappings.component.ts @@ -3,7 +3,7 @@ import { MatRippleModule } from '@angular/material/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; -import { computeTemplateLayout, i18n } from '@placeos/common'; +import { computeTemplateLayout, i18n, LocaleService } from '@placeos/common'; import { AuthenticatedImageDirective, IconComponent, @@ -16,7 +16,6 @@ import { import { SignageContextService } from '../signage-context.service'; import { playlistScheduleExpiryTooltip, - playlistScheduleLabel, playlistScheduleNextPlayLabels, } from '../signage-playlist.util'; import { @@ -25,6 +24,7 @@ import { } from '../signage-template-mapping'; import { SignageTemplateService } from '../templates/signage-template.service'; import { SignageZoneService } from '../zones/signage-zone.service'; +import { scheduleSummary } from './schedule-summary.util'; export type TemplateMappingTargetType = 'display' | 'zone'; @@ -335,6 +335,7 @@ export type TemplateMappingTargetType = 'display' | 'zone'; }) export class TemplateMappingsComponent { private readonly _context = inject(SignageContextService); + private readonly _locale = inject(LocaleService); private readonly _template_service = inject(SignageTemplateService); private readonly _zone_service = inject(SignageZoneService); @@ -396,7 +397,9 @@ export class TemplateMappingsComponent { ); } - public scheduleLabel = playlistScheduleLabel; + public scheduleLabel(schedule: Partial) { + return scheduleSummary(schedule, this._locale.locale); + } public scheduleTooltip(schedule: Partial) { const labels = playlistScheduleNextPlayLabels(schedule); diff --git a/apps/signage-manager/src/app/signage-context.service.ts b/apps/signage-manager/src/app/signage-context.service.ts index 1ada2695b58..10babe4e8b6 100644 --- a/apps/signage-manager/src/app/signage-context.service.ts +++ b/apps/signage-manager/src/app/signage-context.service.ts @@ -8,6 +8,7 @@ import { resource, signal, untracked, + type WritableSignal, } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { @@ -36,6 +37,7 @@ import { } from '@placeos/ts-client'; import { decodeEntityNames } from './shared/decode-entity-names.util'; import { byName } from './shared/paged-search'; +import { storedSignal } from './shared/stored-signal'; import { effectiveFeatures, SIGNAGE_FEATURE_IDS, @@ -65,6 +67,37 @@ const SIGNAGE_SHARE_CONFIG = { } as const; const SIGNAGE_GROUP_STORAGE_KEY = 'PlaceOS.SIGNAGE:selected-group:v1'; +/** Choice made in an approval request modal */ +export interface ApprovalRequestChoice { + approver_id: string; + message: string; +} + +/** Parts of an approval request that differ between playlists and templates */ +export interface ApprovalRequestFlow { + item_id: string; + /** Set while the groups and approvers load */ + loading: WritableSignal; + /** Approve at once, for users who can approve */ + approve: () => Promise; + /** Query of the item type in one group, to find the groups that hold it */ + query: (group_id: string) => Promise<{ data?: { id: string }[] }>; + list_approvers: (group_id: string) => Promise; + /** Let the user pick an approver. Undefined when cancelled. */ + pick: (approvers: A[]) => Promise; + request: ( + group_id: string, + message: string, + approver_id: string, + ) => Promise; + /** Mark the item as waiting for approval */ + requested: () => void; + no_groups_key: string; + no_approvers_key: string; + error_key: string; + success_key: string; +} + /** Signage flags of a group, with the ID of the group they were read for */ interface LoadedGroupFeatures { group_id: string; @@ -105,29 +138,6 @@ export enum SignageGroupPermission { Share = 1 << 7, } -function loadSelectedGroupId() { - if (typeof localStorage === 'undefined') return ''; - try { - return localStorage.getItem(SIGNAGE_GROUP_STORAGE_KEY) || ''; - } catch { - // Local storage can be unavailable in private browsing or restricted embeds. - return ''; - } -} - -function persistSelectedGroupId(group_id: string) { - if (typeof localStorage === 'undefined') return; - try { - if (group_id) { - localStorage.setItem(SIGNAGE_GROUP_STORAGE_KEY, group_id); - } else { - localStorage.removeItem(SIGNAGE_GROUP_STORAGE_KEY); - } - } catch { - // Ignore persistence failures; the selector can still default safely. - } -} - /** Groups with their names decoded, sorted by name */ export function sortGroups(groups: T[]) { return groups.map(decodeEntityNames).sort(byName); @@ -209,8 +219,10 @@ export class SignageContextService { private readonly _org = inject(OrganisationService); private readonly _settings = inject(SettingsService); private readonly _dialog = inject(MatDialog); - private readonly _change = signal(Date.now()); - private readonly _groups_change = signal(Date.now()); + // Counters, not timestamps: two saves in the same millisecond must still + // change the value + private readonly _change = signal(0); + private readonly _groups_change = signal(0); /** Whether the navigation offers a group selector. Hiding it leaves the * section header breadcrumbs as the way to change group. */ @@ -244,7 +256,12 @@ export class SignageContextService { status === 'resolved' || status === 'local' || status === 'error' ); }); - public readonly selected_group_id = signal(loadSelectedGroupId()); + /** Stored, so the app opens in the last group. "All groups" stores none. */ + public readonly selected_group_id = storedSignal( + SIGNAGE_GROUP_STORAGE_KEY, + (stored) => stored || '', + { serialise: (group_id) => group_id || null }, + ); /** * Changes each time the user switches group. Services keep their * selected item in a `linkedSignal` of it, so a switch clears them. @@ -299,7 +316,7 @@ export class SignageContextService { public readonly signage_groups_failed = signal(false); /** Load the signage groups again, after a save or a failed request */ public reloadSignageGroups() { - this._groups_change.set(Date.now()); + this._groups_change.update((count) => count + 1); } // Keeps the groups while a save reloads them, or when the reload fails, // so the selected group and its permissions stay. A new user starts empty. @@ -536,8 +553,6 @@ export class SignageContextService { this.can_manage_all_groups() ? '' : groups[0].group.id, ); }); - - effect(() => persistSelectedGroupId(this.selected_group_id())); } /** Whether the organisation has loaded and the lists can be queried */ @@ -547,7 +562,7 @@ export class SignageContextService { /** Mark signage data as saved, so the lists reload */ public changed() { - this._change.set(Date.now()); + this._change.update((count) => count + 1); } public canManageSignageGroup(group_id = '') { @@ -710,6 +725,48 @@ export class SignageContextService { return groups.filter((_, index) => matches[index]); } + /** + * Ask a group approver to approve an item. Users who can approve + * approve it at once instead. Shows warnings and errors on failure. + */ + public async requestApproval(flow: ApprovalRequestFlow) { + if (flow.loading()) return; + if (this.can_approve()) { + await flow.approve(); + return; + } + let approvers: A[] = []; + let group: PlaceCurrentGroup | undefined; + flow.loading.set(true); + try { + [group] = await this.groupsHolding(flow.item_id, flow.query); + if (!group) { + notifyWarn(i18n(flow.no_groups_key)); + return; + } + approvers = (await flow.list_approvers(group.group.id)) || []; + } catch { + notifyWarn(i18n(flow.no_approvers_key)); + } finally { + flow.loading.set(false); + } + if (!group) return; + const choice = await flow.pick(approvers); + if (!choice) return; + try { + await flow.request( + group.group.id, + choice.message || '', + choice.approver_id || '', + ); + } catch { + notifyError(i18n(flow.error_key)); + return; + } + flow.requested(); + notifySuccess(i18n(flow.success_key)); + } + /** Let the user pick a group from the group select modal */ private async _pickGroup(data: { title: string; diff --git a/apps/signage-manager/src/app/signage-playlist.util.ts b/apps/signage-manager/src/app/signage-playlist.util.ts index 041c31e218b..a939a9674dd 100644 --- a/apps/signage-manager/src/app/signage-playlist.util.ts +++ b/apps/signage-manager/src/app/signage-playlist.util.ts @@ -4,11 +4,8 @@ import { DEFAULT_PLAY_PERIOD_MINUTES, doesCronMatchDay, hasPlayableScheduleMask, - isCronMonthlyWeekday, isPlayOnceSchedule, mediaAnimation, - parseCronNumber, - parseCronWeekdays, parsePlayAtLocal, } from '@placeos/common'; import { @@ -17,21 +14,16 @@ import { type SignagePlaylistItemSchedule, type SignagePlaylistSchedule, } from '@placeos/ts-client'; -import { format, formatDistance, fromUnixTime, getUnixTime } from 'date-fns'; +import { format, fromUnixTime, getUnixTime } from 'date-fns'; import { fromZonedTime, toZonedTime } from 'date-fns-tz'; import { nextCronDates } from './signage-cron.util'; +import { uploadUrl } from './signage-url.util'; /** Format a date as a `play_at_local` value in the viewer's timezone. */ export function formatPlayAtLocal(date: Date | number) { return format(date, "yyyy-MM-dd'T'HH:mm:ss"); } -/** Play once start time for labels. Local times note the display timezone. */ -function playOnceLabel(schedule: Partial) { - const start = playOnceStart(schedule)?.toLocaleString() ?? ''; - return schedule.play_at ? start : `${start} display local time`; -} - /** * Start of a play once schedule, or null for a recurring schedule. * The viewer's timezone resolves `play_at_local` values. @@ -42,16 +34,6 @@ export function playOnceStart(schedule: Partial) { return local_time === null ? null : new Date(local_time); } -const WEEKDAY_NAMES = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', -]; - /** * Default animation of a playlist that ts-client loaded. The API returns * the index of the animation, and `SignagePlaylist` replaces a falsy value @@ -80,9 +62,7 @@ export function playlistMediaThumbnailUrl(item: SignageMedia) { } export function playlistMediaUrl(item: SignageMedia) { - return item?.media_id - ? `/api/engine/v2/uploads/${item.media_id}/url` - : item?.media_uri || ''; + return item?.media_id ? uploadUrl(item.media_id) : item?.media_uri || ''; } export function playlistMediaIcon(item: SignageMedia) { @@ -162,136 +142,13 @@ export function playlistItemScheduleMap(list: { return map; } -/** English ordinal of a number, e.g. "1st" or "12th" */ -export function ordinal(value: number) { - if (value >= 11 && value <= 13) return `${value}th`; - switch (value % 10) { - case 1: - return `${value}st`; - case 2: - return `${value}nd`; - case 3: - return `${value}rd`; - default: - return `${value}th`; - } -} - -function formatCronTime(hour_part: string, minute_part: string) { - const date = new Date(); - date.setHours(+hour_part || 0, +minute_part || 0, 0, 0); - return date.toLocaleTimeString(undefined, { - hour: 'numeric', - minute: '2-digit', - }); -} - -function durationLabel(duration_minutes: number) { - if (!duration_minutes) return 'one playlist pass'; - if (duration_minutes < 60) { - return `${duration_minutes} minute${duration_minutes === 1 ? '' : 's'}`; - } - if (duration_minutes % 60 === 0) { - const hours = duration_minutes / 60; - return `${hours} hour${hours === 1 ? '' : 's'}`; - } - const hours = Math.floor(duration_minutes / 60); - const minutes = duration_minutes % 60; - return `${hours} hr ${minutes} min`; -} - -/** Days of the month in a plain list such as "1,15". Empty for other values. */ -export function parseCronMonthDays(value: string) { - if (!value || value === '*') return []; - const days = value.split(',').map((part) => parseCronNumber(part, 1, 31)); - return days.every((day) => day !== null) - ? [...new Set(days)].sort((a, b) => a - b) - : []; -} - -function listText(values: string[]) { - if (values.length <= 1) return values[0] || ''; - if (values.length === 2) return `${values[0]} and ${values[1]}`; - return `${values.slice(0, -1).join(', ')} and ${values.at(-1)}`; -} - -function weekOfMonthLabel(day_part: string) { - const [start, end] = day_part.split('-').map(Number); - if (start === 1 && end === 7) return '1st'; - if (start === 8 && end === 14) return '2nd'; - if (start === 15 && end === 21) return '3rd'; - if (start === 22 && end === 28) return '4th'; - if (start === 29 && end === 31) return '5th'; - return ''; -} - -function weekOfMonthLabels(day_part: string) { - const labels = day_part.split(',').map((range) => weekOfMonthLabel(range)); - return labels.every((label) => label) ? labels : []; -} - -function humanizeCronSchedule(cron: string, duration_minutes: number) { - const parts = (cron || '0 0 * * *').trim().split(/\s+/); - if (parts.length !== 5) return `Custom schedule (${cron})`; - const [minute, hour, day, month, day_of_week] = parts; - const duration = durationLabel(duration_minutes); - const suffix = ` for ${duration}`; - if (month !== '*') return `Custom schedule (${cron})`; - const minute_interval = /^\*\/(\d+)$/.exec(minute)?.[1]; - if (minute === '*' && hour === '*' && day === '*' && day_of_week === '*') { - return `Every minute${suffix}`; - } - if (minute_interval && hour === '*' && day === '*' && day_of_week === '*') { - return `Every ${minute_interval} minutes${suffix}`; - } - const hour_interval = /^\*\/(\d+)$/.exec(hour)?.[1]; - if (minute === '0' && hour === '*' && day === '*' && day_of_week === '*') { - return `Every hour${suffix}`; - } - if (minute === '0' && hour_interval && day === '*' && day_of_week === '*') { - return `Every ${hour_interval} hours${suffix}`; - } - if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) { - return `Custom schedule (${cron})`; - } - const time = formatCronTime(hour, minute); - if (day === '*' && day_of_week === '*') { - return `Every day at ${time}${suffix}`; - } - if (day === '*' && day_of_week === '1-5') { - return `Weekdays at ${time}${suffix}`; - } - if (day === '*' && day_of_week !== '*') { - const weekdays = parseCronWeekdays(day_of_week).map( - (day_value) => WEEKDAY_NAMES[day_value], - ); - return weekdays.length - ? `Every ${listText(weekdays)} at ${time}${suffix}` - : `Custom schedule (${cron})`; - } - if (day !== '*' && day_of_week === '*') { - const days = parseCronMonthDays(day).map((day_value) => - ordinal(day_value), - ); - return days.length - ? `On the ${listText(days)} of each month at ${time}${suffix}` - : `Custom schedule (${cron})`; - } - if (isCronMonthlyWeekday(day, day_of_week)) { - const weeks = weekOfMonthLabels(day); - const weekdays = parseCronWeekdays(day_of_week).map( - (day_value) => WEEKDAY_NAMES[day_value], - ); - return weeks.length && weekdays.length - ? `On the ${listText(weeks)} ${listText(weekdays)} of each month at ${time}${suffix}` - : `Custom schedule (${cron})`; - } - return `Custom schedule (${cron})`; -} - -function schedulePeriod(schedule: Partial) { +/** + * Play length of a schedule in minutes. A schedule without one plays all + * day. A length of 0 plays the playlist once. + */ +export function playPeriodMinutes(schedule: Partial) { return Number.isFinite(schedule.play_period) - ? schedule.play_period || 0 + ? Math.max(0, schedule.play_period || 0) : DEFAULT_PLAY_PERIOD_MINUTES; } @@ -320,18 +177,6 @@ export function playlistLoopDuration( ); } -export function playlistScheduleExpiryLabel( - schedule: Partial, - now = Date.now(), -) { - if (!schedule.valid_until) return ''; - const expiry = fromUnixTime(schedule.valid_until); - const distance = formatDistance(expiry, new Date(now)); - const relative_time = - expiry.getTime() >= now ? `${distance} from now` : `${distance} ago`; - return `until ${relative_time}`; -} - export function playlistScheduleExpiryTooltip( schedule: Partial, ) { @@ -397,30 +242,6 @@ export function playlistStatus( return requests[playlist.id] ? 'awaiting_review' : 'awaiting_approval'; } -export function playlistScheduleLabel( - schedule: Partial, -) { - const period = schedulePeriod(schedule); - const expiry = playlistScheduleExpiryLabel(schedule); - const suffix = [ - schedule.play_takeover ? 'takeover' : '', - expiry, - schedule.mask - ? `mask ${schedule.mask}, repeats every ${schedule.mask.length} instances` - : '', - ] - .filter((_) => _) - .join(' · '); - if (isPlayOnceSchedule(schedule)) { - return `Plays once on ${playOnceLabel(schedule)} for ${durationLabel(period)}${ - suffix ? ` · ${suffix}` : '' - }`; - } - return `${humanizeCronSchedule(schedule.play_cron || '0 0 * * *', period)}${ - suffix ? ` · ${suffix}` : '' - }`; -} - /** * Count cron instances from valid_from, before applying the repeating mask. * Cache complete days so successive preview times do not recount the past. @@ -505,10 +326,16 @@ export function createScheduleMaskFilter( /** * Date and time of a play, e.g. "Mon, Jan 5, 9:00 AM". * @param timeZone IANA timezone. The viewer's timezone when not set. + * @param hour12 12 hour clock when true, 24 hour when false. The viewer's locale decides when not set. */ -export function formatPlayDateTime(date: Date, timeZone?: string) { +export function formatPlayDateTime( + date: Date, + timeZone?: string, + hour12?: boolean, +) { return date.toLocaleString(undefined, { timeZone, + hour12, weekday: 'short', month: 'short', day: 'numeric', @@ -568,7 +395,7 @@ function nextSchedulePlays( count: number, now: number, ): PlaySession[] { - const period = schedulePeriod(schedule); + const period = playPeriodMinutes(schedule); if (isPlayOnceSchedule(schedule)) { const start = playOnceStart(schedule); if (!start) return []; @@ -606,7 +433,7 @@ export function playlistNextPlayTime( // One-off plays already count until they end. const from = isPlayOnceSchedule(schedule) ? now - : now - schedulePeriod(schedule) * 60_000; + : now - playPeriodMinutes(schedule) * 60_000; const [play] = nextSchedulePlays(schedule, 1, from); if (play) next = Math.min(next, Math.max(play.start.getTime(), now)); } diff --git a/apps/signage-manager/src/app/signage-url.util.ts b/apps/signage-manager/src/app/signage-url.util.ts index f2561710130..f6665bb1d59 100644 --- a/apps/signage-manager/src/app/signage-url.util.ts +++ b/apps/signage-manager/src/app/signage-url.util.ts @@ -21,3 +21,23 @@ export function isWebPageUrl(url: string): boolean { export function webPageFrameUrl(url: string): string { return isWebPageUrl(url) ? url : 'about:blank'; } + +/** URL that serves an upload, relative to the page origin */ +export function uploadUrl(upload_id: string) { + return `/api/engine/v2/uploads/${encodeURIComponent(upload_id)}/url`; +} + +/** + * Link to a route of the signage player, with debug output on. + * @param setting Player path from the `signage_path` setting. Zone metadata + * can override it, so a scheme that would run script in this origin, such + * as `javascript:`, falls back to `/signage`. + * @param route Player route after `#/`, with its parts already encoded + */ +export function signagePlayerUrl(setting: string | undefined, route: string) { + const signage_path = + setting && parseWebUrl(setting, document.baseURI) + ? setting + : '/signage'; + return `${signage_path.replace(/\/$/, '')}/#/${route}?debug=true`; +} diff --git a/apps/signage-manager/src/app/templates/signage-template.service.ts b/apps/signage-manager/src/app/templates/signage-template.service.ts index 13cdf0cd031..8f3e4fc667e 100644 --- a/apps/signage-manager/src/app/templates/signage-template.service.ts +++ b/apps/signage-manager/src/app/templates/signage-template.service.ts @@ -16,12 +16,10 @@ import { notifyWarn, OrganisationService, } from '@placeos/common'; -import { ConfirmModalComponent, openConfirmModal } from '@placeos/components'; import { addSignageTemplate, addSignageTemplateMapping, listSignageTemplateApprovers, - PlaceCurrentGroup, query, querySignageTemplates, removeSignageTemplate, @@ -36,6 +34,7 @@ import { updateSignageTemplate, updateSignageTemplateMapping, } from '@placeos/ts-client'; +import { confirmThenRun } from '../shared/confirm-then-run'; import { decodeEntityNames } from '../shared/decode-entity-names.util'; import { PagedList } from '../shared/paged-list'; import { byName } from '../shared/paged-search'; @@ -387,7 +386,8 @@ export class SignageTemplateService { ) ) return false; - const result = await openConfirmModal( + const removed = await confirmThenRun( + this._dialog, { title: i18n( 'SIGNAGE_MANAGER.SVC_REMOVE_TEMPLATE_MAPPING_TITLE', @@ -398,22 +398,16 @@ export class SignageTemplateService { ), icon: { content: 'delete' }, }, - this._dialog, + { + loading_key: 'SIGNAGE_MANAGER.SVC_REMOVING', + error_key: 'SIGNAGE_MANAGER.SVC_TEMPLATE_MAPPING_REMOVE_ERROR', + action: () => removeSignageTemplateMapping(mapping.id), + }, ); - if (result.reason !== 'done') return false; - try { - await removeSignageTemplateMapping(mapping.id); - this.template_mappings_revision.update((value) => value + 1); - result.close(); - notifySuccess(i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_MAPPING_REMOVED')); - return true; - } catch { - result.close(); - notifyError( - i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_MAPPING_REMOVE_ERROR'), - ); - return false; - } + if (!removed) return false; + this.template_mappings_revision.update((value) => value + 1); + notifySuccess(i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_MAPPING_REMOVED')); + return true; } public async approveTemplate(template: SignageTemplate) { @@ -436,56 +430,42 @@ export class SignageTemplateService { public async requestTemplateApproval(template: SignageTemplate) { if (!template?.id || this.template_approval_request_loading()) return; if (this._templateLayoutUnsaved(template)) return; - if (this._context.can_approve()) { - await this.approveTemplate(template); - return; - } - let approvers: SignageTemplateApprover[] = []; - let group: PlaceCurrentGroup | null = null; - this.template_approval_request_loading.set(true); - try { - [group] = await this._context.groupsHolding( - template.id, - (group_id) => querySignageTemplates({ group_id, limit: 500 }), - ); - if (!group) { - notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_GROUPS_FOR_TEMPLATE')); - return; - } - approvers = - ((await listSignageTemplateApprovers( - group.group.id, - )) as SignageTemplateApprover[]) || []; - } catch { - notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_TEMPLATE_APPROVERS')); - } finally { - this.template_approval_request_loading.set(false); - } - if (!group) return; - const { TemplateRequestApprovalModalComponent } = - await import('../shared/template-request-approval-modal.component'); - const ref = this._dialog.open(TemplateRequestApprovalModalComponent, { - data: { template, approvers }, - panelClass: 'mobile-fullscreen', + await this._context.requestApproval({ + item_id: template.id, + loading: this.template_approval_request_loading, + approve: () => this.approveTemplate(template), + query: (group_id) => + querySignageTemplates({ group_id, limit: 500 }), + list_approvers: (group_id) => + listSignageTemplateApprovers(group_id) as Promise< + SignageTemplateApprover[] + >, + pick: async (approvers) => { + const { TemplateRequestApprovalModalComponent } = + await import('../shared/template-request-approval-modal.component'); + const ref = this._dialog.open( + TemplateRequestApprovalModalComponent, + { + data: { template, approvers }, + panelClass: 'mobile-fullscreen', + }, + ); + return dialogClosed(ref); + }, + request: (group_id, message, approver_id) => + requestApprovalSignageTemplate( + template.id, + group_id, + message, + approver_id, + ), + requested: () => + this.setTemplateApprovalStatus(template.id, false, true), + no_groups_key: 'SIGNAGE_MANAGER.SVC_NO_GROUPS_FOR_TEMPLATE', + no_approvers_key: 'SIGNAGE_MANAGER.SVC_NO_TEMPLATE_APPROVERS', + error_key: 'SIGNAGE_MANAGER.SVC_TEMPLATE_APPROVAL_REQUEST_ERROR', + success_key: 'SIGNAGE_MANAGER.SVC_TEMPLATE_APPROVAL_REQUESTED', }); - const result: TemplateRequestApprovalModalResult | undefined = - await dialogClosed(ref); - if (!result) return; - try { - await requestApprovalSignageTemplate( - template.id, - group.group.id, - result.message || '', - result.approver_id || '', - ); - } catch { - notifyError( - i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_APPROVAL_REQUEST_ERROR'), - ); - return; - } - this.setTemplateApprovalStatus(template.id, false, true); - notifySuccess(i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_APPROVAL_REQUESTED')); } /** @@ -501,7 +481,8 @@ export class SignageTemplateService { ) ) return false; - const result = await openConfirmModal( + const removed = await confirmThenRun( + this._dialog, { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_TEMPLATE_TITLE'), content: i18n('SIGNAGE_MANAGER.SVC_DELETE_NAMED', { @@ -509,19 +490,17 @@ export class SignageTemplateService { }), icon: { content: 'delete' }, }, - this._dialog, + { + loading_key: 'SIGNAGE_MANAGER.SVC_REMOVING', + error_key: 'SIGNAGE_MANAGER.SVC_TEMPLATE_REMOVE_ERROR', + action: () => + removeSignageTemplate( + template.id, + this._context.groupQueryParams({}), + ), + }, ); - if (result.reason !== 'done') return false; - try { - await removeSignageTemplate( - template.id, - this._context.groupQueryParams({}), - ); - } catch { - result.close(); - notifyError(i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_REMOVE_ERROR')); - return false; - } + if (!removed) return false; this._releaseDraft(template.id); // The reload keeps the loaded rows on screen, so drop this one now this._template_list.update((items) => @@ -534,7 +513,6 @@ export class SignageTemplateService { } this._context.changed(); notifySuccess(i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_REMOVED')); - result.close(); return true; } @@ -632,7 +610,8 @@ export class SignageTemplateService { ) ) return false; - const result = await openConfirmModal( + const undone = await confirmThenRun( + this._dialog, { title: i18n('SIGNAGE_MANAGER.UNDO_CHANGES'), content: i18n('SIGNAGE_MANAGER.TEMPLATE_REVERT_CONFIRM', { @@ -641,24 +620,13 @@ export class SignageTemplateService { confirm_text: i18n('SIGNAGE_MANAGER.UNDO_CHANGES'), icon: { content: 'undo' }, }, - this._dialog, + { + loading_key: 'SIGNAGE_MANAGER.UNDOING_CHANGES', + error_key: 'SIGNAGE_MANAGER.TEMPLATE_REVERT_ERROR', + action: () => removeSignageTemplateDraft(template_id), + }, ); - if (result.reason !== 'done') return false; - result.loading(i18n('SIGNAGE_MANAGER.UNDOING_CHANGES')); - // Keep the confirmation open until the draft is gone. Closing it - // would not stop the request. - const confirm_ref = this._dialog.openDialogs.at(-1); - if (confirm_ref?.componentInstance instanceof ConfirmModalComponent) { - confirm_ref.disableClose = true; - } - try { - await removeSignageTemplateDraft(template_id); - } catch { - notifyError(i18n('SIGNAGE_MANAGER.TEMPLATE_REVERT_ERROR')); - return false; - } finally { - result.close(); - } + if (!undone) return false; this.updateCachedTemplate(previous_version); notifySuccess(i18n('SIGNAGE_MANAGER.TEMPLATE_REVERTED')); this._context.changed(); diff --git a/apps/signage-manager/src/app/templates/template-layout-list.component.ts b/apps/signage-manager/src/app/templates/template-layout-list.component.ts index 8bad4ec4336..a8dfeb8d4a4 100644 --- a/apps/signage-manager/src/app/templates/template-layout-list.component.ts +++ b/apps/signage-manager/src/app/templates/template-layout-list.component.ts @@ -21,7 +21,7 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; -import { i18n, notifyWarn } from '@placeos/common'; +import { i18n, LocaleService, notifyWarn } from '@placeos/common'; import { AuthenticatedImageDirective, buildFormFromFields, @@ -38,12 +38,12 @@ import { SignageTemplateMapping, } from '@placeos/ts-client'; import { SignageDisplayService } from '../displays/signage-display.service'; +import { scheduleSummary } from '../shared/schedule-summary.util'; import { SectionTab, SectionTabsComponent, } from '../shared/section-tabs.component'; import { SignageContextService } from '../signage-context.service'; -import { playlistScheduleLabel } from '../signage-playlist.util'; import { SignagePluginService } from '../signage-plugin.service'; import { pluginName, @@ -737,6 +737,7 @@ import { }) export class TemplateLayoutListComponent { private readonly _context = inject(SignageContextService); + private readonly _locale = inject(LocaleService); private readonly _display_service = inject(SignageDisplayService); private readonly _plugin_service = inject(SignagePluginService); private readonly _template_service = inject(SignageTemplateService); @@ -858,7 +859,7 @@ export class TemplateLayoutListComponent { public mappingSchedule(mapping: SignageTemplateMapping) { return mapping.schedule - ? playlistScheduleLabel(mapping.schedule) + ? scheduleSummary(mapping.schedule, this._locale.locale) : i18n('SIGNAGE_MANAGER.DEFAULT_TEMPLATE'); } diff --git a/apps/signage-manager/src/app/templates/template-preview.component.ts b/apps/signage-manager/src/app/templates/template-preview.component.ts index b7a49a9f613..1a763ad2c86 100644 --- a/apps/signage-manager/src/app/templates/template-preview.component.ts +++ b/apps/signage-manager/src/app/templates/template-preview.component.ts @@ -29,6 +29,7 @@ import { mediaThumbnail } from '@placeos/ts-client'; import { SignageDisplayService } from '../displays/signage-display.service'; import { SignagePluginService } from '../signage-plugin.service'; import { pluginName } from '../signage-plugin.util'; +import { signagePlayerUrl } from '../signage-url.util'; import { SignageTemplateService } from './signage-template.service'; import { applyLayoutPositionDefaults, @@ -330,14 +331,10 @@ export class TemplatePreviewComponent { const template_id = this.live_template_id(); const display_id = this.selected_display_id(); if (!template_id || !display_id) return ''; - // Zone metadata can override the path, so refuse schemes that would - // run script in this origin, such as `javascript:` - const setting = this.signage_path(); - const signage_path = - setting && parseWebUrl(setting, document.baseURI) - ? setting - : '/signage'; - return `${signage_path.replace(/\/$/, '')}/#/template/${encodeURIComponent(template_id)}/${encodeURIComponent(display_id)}?debug=true`; + return signagePlayerUrl( + this.signage_path(), + `template/${encodeURIComponent(template_id)}/${encodeURIComponent(display_id)}`, + ); }); /** diff --git a/apps/signage-manager/src/app/zones/signage-zone.service.ts b/apps/signage-manager/src/app/zones/signage-zone.service.ts index 933ff2fcac4..af2f7ff8d7c 100644 --- a/apps/signage-manager/src/app/zones/signage-zone.service.ts +++ b/apps/signage-manager/src/app/zones/signage-zone.service.ts @@ -1,14 +1,12 @@ import { computed, debounced, - effect, inject, Injectable, linkedSignal, type Resource, resource, signal, - untracked, } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { @@ -17,7 +15,6 @@ import { notifySuccess, OrganisationService, } from '@placeos/common'; -import { openConfirmModal } from '@placeos/components'; import { addZone as createZone, removeZone as deleteZone, @@ -30,7 +27,9 @@ import { } from '@placeos/ts-client'; import { displayZoneIds, type ZoneNode } from '../displays/display-zones.util'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; +import { confirmThenRun } from '../shared/confirm-then-run'; import { decodeEntityNames } from '../shared/decode-entity-names.util'; +import { PlaylistAssignments } from '../shared/playlist-assignments'; import { SignageContextService } from '../signage-context.service'; import { dialogClosed, mergeItems } from '../signage-service.util'; import type { ZoneEditFormModel } from './zone-edit-modal.component'; @@ -58,15 +57,6 @@ export class SignageZoneService { private readonly _zone_overrides = signal>({}); - // Local edits only bridge the gap until the lists reload. Drop them when - // the group or data changes, so a stale copy never hides newer server data - // or sends an old version with the next patch. - private readonly _clear_overrides = effect(() => { - this._context.api_group_id(); - this._context.data_change(); - untracked(() => this._zone_overrides.set({})); - }); - /** Search reachable zones, or the direct children of a selected parent. */ public querySelectableZones( search: string, @@ -252,6 +242,18 @@ export class SignageZoneService { source: this._context.group_switch, computation: () => null, }); + private readonly _assignments = new PlaylistAssignments({ + context: this._context, + playlist_service: this._playlist_service, + dialog: this._dialog, + overrides: this._zone_overrides, + selected: this.selected_zone, + target: 'zone_id', + already_assigned_key: 'SIGNAGE_MANAGER.SVC_PLAYLIST_IN_ZONE', + add_error_key: 'SIGNAGE_MANAGER.SVC_ASSIGNMENT_ERROR', + save_playlists: (zone, playlists) => + updateZone(zone.id, { playlists, version: zone.version }, 'patch'), + }); // Cleared when the selected zone changes, as the search is within it public readonly zone_search_term = linkedSignal({ source: () => this.selected_zone()?.id, @@ -300,30 +302,11 @@ export class SignageZoneService { ); } - /** - * Keep a saved zone until the lists reload. - * @returns The zone with its names decoded, to use for selection - */ - private _cacheZone(zone: PlaceZone) { - const item = decodeEntityNames(zone); - if (!item?.id) return item; - this._zone_overrides.update((state) => ({ - ...state, - [item.id]: item, - })); - return item; - } - public async addPlaylistToZone(zone: PlaceZone) { - if (!this._canAssign()) return; - const { PlaylistSelectModalComponent } = - await import('../shared/playlist-select-modal.component'); - const ref = this._dialog.open(PlaylistSelectModalComponent, { - panelClass: 'mobile-fullscreen', - }); - const playlist_id = await dialogClosed(ref); + if (!this._assignments.canAssign()) return; + const playlist_id = await this._assignments.pickPlaylist(); if (!playlist_id) return; - const updated = await this._addZonePlaylist( + const updated = await this._assignments.add( zone, { playlist_id }, 'SIGNAGE_MANAGER.SVC_PLAYLIST_ADDED_ZONE', @@ -332,7 +315,7 @@ export class SignageZoneService { } public async removePlaylistFromZone(zone: PlaceZone, playlist_id: string) { - const updated = await this._removeZonePlaylist( + const updated = await this._assignments.remove( zone, playlist_id, 'SIGNAGE_MANAGER.SVC_PLAYLIST_REMOVED_ZONE', @@ -341,7 +324,7 @@ export class SignageZoneService { } public async addZoneToPlaylist(playlist: SignagePlaylist) { - if (!this._canAssign()) return; + if (!this._assignments.canAssign()) return; const { ZoneSelectModalComponent } = await import('../shared/zone-select-modal.component'); const ref = this._dialog.open(ZoneSelectModalComponent, { @@ -358,104 +341,24 @@ export class SignageZoneService { notifyError(i18n('SIGNAGE_MANAGER.SVC_ASSIGNMENT_ERROR')); return; } - const updated = await this._addZonePlaylist( + const updated = await this._assignments.add( zone, { playlist_id: playlist.id, playlist }, 'SIGNAGE_MANAGER.SVC_ZONE_ADDED_PLAYLIST', ); - this._updateSelection(updated); + this._assignments.updateSelection(updated); } public async removeZoneFromPlaylist( playlist: SignagePlaylist, zone: PlaceZone, ) { - const updated = await this._removeZonePlaylist( + const updated = await this._assignments.remove( zone, playlist.id, 'SIGNAGE_MANAGER.SVC_ZONE_REMOVED_PLAYLIST', ); - this._updateSelection(updated); - } - - private _canAssign() { - return this._context.requirePermission( - this._context.can_update(), - 'SIGNAGE_MANAGER.SVC_NO_UPDATE_ASSIGNMENTS', - ); - } - - /** Show a saved zone in place of the selected zone, when it is that zone */ - private _updateSelection(zone: PlaceZone | null) { - if (zone && this.selected_zone()?.id === zone.id) { - this.selected_zone.set(zone); - } - } - - /** - * Add a playlist to a zone, after a check for takeover conflicts. - * @returns The saved zone, or null when nothing was saved - */ - private async _addZonePlaylist( - zone: PlaceZone, - change: { playlist_id: string; playlist?: SignagePlaylist }, - success_key: string, - ) { - if (zone.playlists?.includes(change.playlist_id)) { - notifyError(i18n('SIGNAGE_MANAGER.SVC_PLAYLIST_IN_ZONE')); - return null; - } - const confirmed = await this._playlist_service.confirmTakeoverChange({ - ...change, - zone_id: zone.id, - }); - if (!confirmed) return null; - return this._saveZonePlaylists( - zone, - [...(zone.playlists || []), change.playlist_id], - success_key, - ); - } - - /** - * Take a playlist off a zone. - * @returns The saved zone, or null when nothing was saved - */ - private async _removeZonePlaylist( - zone: PlaceZone, - playlist_id: string, - success_key: string, - ) { - if (!this._canAssign()) return null; - return this._saveZonePlaylists( - zone, - (zone.playlists || []).filter((id) => id !== playlist_id), - success_key, - ); - } - - /** - * Save the playlists of a zone and keep the result until the lists reload. - * @returns The saved zone, or null after showing an error when it fails - */ - private async _saveZonePlaylists( - zone: PlaceZone, - playlists: string[], - success_key: string, - ) { - const updated = await updateZone( - zone.id, - { playlists, version: zone.version }, - 'patch', - ).catch(() => null); - if (!updated) { - notifyError(i18n('SIGNAGE_MANAGER.SVC_ASSIGNMENT_ERROR')); - return null; - } - const saved = this._cacheZone(updated); - this._context.changed(); - notifySuccess(i18n(success_key)); - return saved; + this._assignments.updateSelection(updated); } public async addZone() { @@ -532,7 +435,7 @@ export class SignageZoneService { notifyError(i18n('SIGNAGE_MANAGER.SVC_SIGNAGE_ZONE_SAVE_ERROR')); return null; } - const saved = this._cacheZone(result); + const saved = this._assignments.cache(result); this.zone_tree_children_cache.set({}); this.selected_zone.set(saved); this._context.changed(); @@ -561,7 +464,8 @@ export class SignageZoneService { public async removeZone(zone: PlaceZone) { if (!zone?.id || !zone.tags?.includes('signage')) return false; if (!this._canManageZones()) return false; - const result = await openConfirmModal( + const removed = await confirmThenRun( + this._dialog, { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_SIGNAGE_ZONE_TITLE'), content: i18n('SIGNAGE_MANAGER.SVC_DELETE_NAMED', { @@ -569,18 +473,13 @@ export class SignageZoneService { }), icon: { content: 'delete' }, }, - this._dialog, - ); - if (result.reason !== 'done') return false; - const removed = await deleteZone(zone.id).then( - () => true, - () => false, + { + loading_key: 'SIGNAGE_MANAGER.SVC_REMOVING', + error_key: 'SIGNAGE_MANAGER.SVC_SIGNAGE_ZONE_REMOVE_ERROR', + action: () => deleteZone(zone.id), + }, ); - result.close(); - if (!removed) { - notifyError(i18n('SIGNAGE_MANAGER.SVC_SIGNAGE_ZONE_REMOVE_ERROR')); - return false; - } + if (!removed) return false; this._zone_overrides.update((overrides) => { const next = { ...overrides }; delete next[zone.id]; diff --git a/apps/signage-manager/src/tests/media/signage-media.service.spec.ts b/apps/signage-manager/src/tests/media/signage-media.service.spec.ts index f7da6b5e486..b68efd0b10a 100644 --- a/apps/signage-manager/src/tests/media/signage-media.service.spec.ts +++ b/apps/signage-manager/src/tests/media/signage-media.service.spec.ts @@ -589,6 +589,7 @@ describe('SignageMediaService', () => { expect.stringMatching( new RegExp(`/uploads/screenshot-${screenshot_count}$`), ), + { response_type: 'void' }, ); }); @@ -650,6 +651,7 @@ describe('SignageMediaService', () => { expect.stringMatching( new RegExp(`/uploads/screenshot-${screenshot_count}$`), ), + { response_type: 'void' }, ); expect(addSignageMedia).toHaveBeenCalledWith( expect.not.objectContaining({ diff --git a/apps/signage-manager/src/tests/playlists/playlist-item-details.component.spec.ts b/apps/signage-manager/src/tests/playlists/playlist-item-details.component.spec.ts index 415db9ddcf5..d324bcc087e 100644 --- a/apps/signage-manager/src/tests/playlists/playlist-item-details.component.spec.ts +++ b/apps/signage-manager/src/tests/playlists/playlist-item-details.component.spec.ts @@ -215,7 +215,7 @@ describe('PlaylistItemDetailsComponent', () => { const component = await make(); const labels = component.schedule_labels(); expect(labels.length).toBe(1); - expect(labels[0]).toContain('Every day at'); + expect(labels[0]).toContain('Starts every day at'); expect(labels[0]).toContain('for 3 hours'); expect(labels[0]).toContain(' · until '); expect(component.schedule_expiry_tooltips()).toEqual([ @@ -264,7 +264,7 @@ describe('PlaylistItemDetailsComponent', () => { schedules: [{ play_cron: '0 9 * * *', valid_from, mask: '10' }], }); const component = await make(); - expect(component.schedule_labels()[0]).toContain('mask 10'); + expect(component.schedule_labels()[0]).toContain('Mask 10'); // A mask that skips every occurrence never plays selected_playlist.set({ diff --git a/apps/signage-manager/src/tests/playlists/signage-playlist.service.spec.ts b/apps/signage-manager/src/tests/playlists/signage-playlist.service.spec.ts index 71ae32e8bec..b3a9430bd86 100644 --- a/apps/signage-manager/src/tests/playlists/signage-playlist.service.spec.ts +++ b/apps/signage-manager/src/tests/playlists/signage-playlist.service.spec.ts @@ -409,6 +409,42 @@ describe('SignagePlaylistService', () => { ); }); + it('restores the approved media after the user confirms the undo', async () => { + const service = createService(); + const refresh = vi.spyOn(service, 'refreshPlaylist'); + confirmNextDialog(); + vi.mocked(updateSignagePlaylistMedia).mockResolvedValue({} as never); + + const undone = await service.undoPlaylistChanges( + new SignagePlaylist({ id: 'pl-1', name: 'Lobby' }), + new SignagePlaylistMedia({ items: ['media-1'] }), + ); + + expect(undone).toBe(true); + expect(updateSignagePlaylistMedia).toHaveBeenCalledWith('pl-1', [ + 'media-1', + ]); + expect(refresh).toHaveBeenCalledWith('pl-1'); + expect(dialog.open.mock.results[0].value.close).toHaveBeenCalled(); + }); + + it('keeps the playlist media when the user cancels the undo', async () => { + const service = createService(); + dialog.open.mockReturnValue({ + componentInstance: { event: NEVER, loading: { set: vi.fn() } }, + afterClosed: () => of(undefined), + close: vi.fn(), + }); + + const undone = await service.undoPlaylistChanges( + new SignagePlaylist({ id: 'pl-1', name: 'Lobby' }), + new SignagePlaylistMedia({ items: ['media-1'] }), + ); + + expect(undone).toBe(false); + expect(updateSignagePlaylistMedia).not.toHaveBeenCalled(); + }); + it('closes the confirm modal and restores the items when removing them fails', async () => { const service = createService(); showPlaylistMedia(service, 'playlist-1', { diff --git a/apps/signage-manager/src/tests/shared/playlist-approve-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/playlist-approve-modal.component.spec.ts index c4ccbbf190a..d90befcce51 100644 --- a/apps/signage-manager/src/tests/shared/playlist-approve-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/playlist-approve-modal.component.spec.ts @@ -6,7 +6,6 @@ import { approveSignagePlaylist, listSignagePlaylistMediaRevisions, SignagePlaylistMedia, - updateSignagePlaylistMedia, } from '@placeos/ts-client'; import { MediaPreviewService } from '../../app/media/media-preview.service'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; @@ -34,8 +33,8 @@ describe('PlaylistApproveModalComponent', () => { previewMedia: vi.fn(), }; const playlist_service = { - refreshPlaylist: vi.fn(), setPlaylistApprovalStatus: vi.fn(), + undoPlaylistChanges: vi.fn(), }; beforeEach(async () => { @@ -43,8 +42,7 @@ describe('PlaylistApproveModalComponent', () => { setNotifyOutlet({ open: notify_open } as any, true); dialog_ref.disableClose = false; context.can_update.set(true); - playlist_service.refreshPlaylist.mockReset(); - (updateSignagePlaylistMedia as any).mockResolvedValue({}); + playlist_service.undoPlaylistChanges.mockResolvedValue(true); (listSignagePlaylistMediaRevisions as any).mockResolvedValue([ { id: 'current-version', @@ -178,37 +176,22 @@ describe('PlaylistApproveModalComponent', () => { expect(button_text).not.toContain('Undo Changes'); }); - it('does not undo changes when user does not have update permissions', async () => { - context.can_update.set(false); - const fixture = TestBed.createComponent(PlaylistApproveModalComponent); - const component = fixture.componentInstance; - - await component.undoChanges(); - - expect(updateSignagePlaylistMedia).not.toHaveBeenCalled(); - expect(notify_open).toHaveBeenCalledWith( - 'You cannot update playlists in this group.', - expect.anything(), - expect.objectContaining({ panelClass: ['warn'] }), - ); - }); - - it('refreshes selected playlist data after undoing changes', async () => { + it('restores the approved version through the service', async () => { const fixture = TestBed.createComponent(PlaylistApproveModalComponent); const component = fixture.componentInstance; + const previous = { id: 'previous-version', items: ['media-2'] }; (component as any).playlist_versions = () => [ { id: 'current-version', items: ['media-1'] }, - { id: 'previous-version', items: ['media-2'] }, + previous, ]; await component.undoChanges(); - expect(updateSignagePlaylistMedia).toHaveBeenCalledWith('playlist-1', [ - 'media-2', - ]); - expect(playlist_service.refreshPlaylist).toHaveBeenCalledWith( - 'playlist-1', + expect(playlist_service.undoPlaylistChanges).toHaveBeenCalledWith( + { id: 'playlist-1' }, + previous, ); + expect(dialog_ref.close).toHaveBeenCalledWith(true); }); it('blocks approval and shows an error when the versions fail to load', async () => { @@ -252,9 +235,10 @@ describe('PlaylistApproveModalComponent', () => { await component.undoChanges(); - expect(updateSignagePlaylistMedia).toHaveBeenCalledWith('playlist-1', [ - 'media-1', - ]); + expect(playlist_service.undoPlaylistChanges).toHaveBeenCalledWith( + { id: 'playlist-1' }, + expect.objectContaining({ id: 'approved', items: ['media-1'] }), + ); }); it('shows fallback icons in preview lists', async () => { diff --git a/apps/signage-manager/src/tests/shared/playlist-edit-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/playlist-edit-modal.component.spec.ts index 430c261796c..49626162f87 100644 --- a/apps/signage-manager/src/tests/shared/playlist-edit-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/playlist-edit-modal.component.spec.ts @@ -1,7 +1,11 @@ import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { form } from '@angular/forms/signals'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { + MAT_DIALOG_DATA, + MatDialog, + MatDialogRef, +} from '@angular/material/dialog'; import { HotkeysService, setNotifyOutlet } from '@placeos/common'; import { MediaAnimation, SignagePlaylist } from '@placeos/ts-client'; import { PlaylistEditModalComponent } from '../../app/shared/playlist-edit-modal.component'; @@ -24,10 +28,12 @@ describe('PlaylistEditModalComponent', () => { const onEdit = vi.fn(); const before_save = vi.fn(); const hotkey_listen = vi.fn(); + const dialog = { openDialogs: [] as unknown[] }; let hotkey_callback: () => void; beforeEach(async () => { vi.clearAllMocks(); + dialog.openDialogs = [dialog_ref]; setNotifyOutlet({ open: notify_open } as any, true); dialog_ref.disableClose = false; onEdit.mockResolvedValue({ id: 'playlist-1' }); @@ -63,6 +69,7 @@ describe('PlaylistEditModalComponent', () => { }, }, { provide: MatDialogRef, useValue: dialog_ref }, + { provide: MatDialog, useValue: dialog }, { provide: HotkeysService, useValue: { listen: hotkey_listen }, @@ -148,6 +155,7 @@ describe('PlaylistEditModalComponent', () => { }, }, { provide: MatDialogRef, useValue: dialog_ref }, + { provide: MatDialog, useValue: dialog }, { provide: HotkeysService, useValue: { listen: hotkey_listen }, @@ -353,6 +361,18 @@ describe('PlaylistEditModalComponent', () => { expect(save).toHaveBeenCalled(); }); + it('ignores the S hotkey while another dialog is on top', () => { + const fixture = TestBed.createComponent(PlaylistEditModalComponent); + const save = vi + .spyOn(fixture.componentInstance, 'savePlaylist') + .mockResolvedValue(); + dialog.openDialogs = [dialog_ref, { id: 'confirm' }]; + + hotkey_callback(); + + expect(save).not.toHaveBeenCalled(); + }); + it('ignores the S hotkey while a select has focus', () => { const fixture = TestBed.createComponent(PlaylistEditModalComponent); const save = vi @@ -412,7 +432,7 @@ describe('PlaylistEditModalComponent', () => { }, ], })); - component.addSchedule(); + component.schedule_list.add(); const play_at = Date.UTC(2026, 2, 2, 10, 30); component.model.update((model) => ({ ...model, @@ -466,7 +486,7 @@ describe('PlaylistEditModalComponent', () => { const component = fixture.componentInstance; const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() }; - component.removeSchedule(event as any, 0); + component.schedule_list.remove(event as any, 0); expect(component.model().schedules.length).toBe(1); expect(event.stopPropagation).toHaveBeenCalled(); @@ -476,19 +496,19 @@ describe('PlaylistEditModalComponent', () => { const fixture = TestBed.createComponent(PlaylistEditModalComponent); const component = fixture.componentInstance; - component.addSchedule(); - expect(component.isScheduleOpen(1)).toBe(true); - expect(component.isScheduleOpen(0)).toBe(false); + component.schedule_list.add(); + expect(component.schedule_list.isOpen(1)).toBe(true); + expect(component.schedule_list.isOpen(0)).toBe(false); - component.openSchedule(0); + component.schedule_list.toggle(0); - expect(component.isScheduleOpen(0)).toBe(true); - expect(component.isScheduleOpen(1)).toBe(false); + expect(component.schedule_list.isOpen(0)).toBe(true); + expect(component.schedule_list.isOpen(1)).toBe(false); - component.openSchedule(0); + component.schedule_list.toggle(0); - expect(component.isScheduleOpen(0)).toBe(false); - expect(component.isScheduleOpen(1)).toBe(false); + expect(component.schedule_list.isOpen(0)).toBe(false); + expect(component.schedule_list.isOpen(1)).toBe(false); }); it('shows a summary for one-off schedules', () => { diff --git a/apps/signage-manager/src/tests/shared/playlist-item-schedule-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/playlist-item-schedule-modal.component.spec.ts index b4f5436247a..5559a7451b4 100644 --- a/apps/signage-manager/src/tests/shared/playlist-item-schedule-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/playlist-item-schedule-modal.component.spec.ts @@ -161,28 +161,28 @@ describe('PlaylistItemScheduleModalComponent', () => { const component = await createComponent(); expect(component.model().schedules.length).toBe(1); - expect(component.isScheduleOpen(0)).toBe(true); + expect(component.schedule_list.isOpen(0)).toBe(true); }); it('adds a schedule and focuses the new entry', async () => { const component = await createComponent(); - component.addSchedule(); + component.schedule_list.add(); expect(component.model().schedules.length).toBe(2); - expect(component.isScheduleOpen(1)).toBe(true); - expect(component.isScheduleOpen(0)).toBe(false); + expect(component.schedule_list.isOpen(1)).toBe(true); + expect(component.schedule_list.isOpen(0)).toBe(false); }); it('removes a schedule but keeps at least one', async () => { const component = await createComponent(); const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() }; - component.addSchedule(); + component.schedule_list.add(); - component.removeSchedule(event as any, 1); + component.schedule_list.remove(event as any, 1); expect(component.model().schedules.length).toBe(1); - component.removeSchedule(event as any, 0); + component.schedule_list.remove(event as any, 0); expect(component.model().schedules.length).toBe(1); expect(event.preventDefault).toHaveBeenCalled(); }); @@ -204,9 +204,9 @@ describe('PlaylistItemScheduleModalComponent', () => { .queryAll(By.directive(ScheduleFormStubComponent)) .map(({ componentInstance }) => componentInstance); const [, second, third] = forms(); - component.openSchedule(2); + component.schedule_list.toggle(2); - component.removeSchedule( + component.schedule_list.remove( { preventDefault() {}, stopPropagation() {} } as Event, 0, ); @@ -214,17 +214,17 @@ describe('PlaylistItemScheduleModalComponent', () => { expect(forms()[0]).toBe(second); expect(forms()[1]).toBe(third); - expect(component.isScheduleOpen(1)).toBe(true); + expect(component.schedule_list.isOpen(1)).toBe(true); }); it('collapses an open schedule when toggled again', async () => { const component = await createComponent(); - component.openSchedule(0); - expect(component.isScheduleOpen(0)).toBe(false); + component.schedule_list.toggle(0); + expect(component.schedule_list.isOpen(0)).toBe(false); - component.openSchedule(0); - expect(component.isScheduleOpen(0)).toBe(true); + component.schedule_list.toggle(0); + expect(component.schedule_list.isOpen(0)).toBe(true); }); it('requires a start and preserves an item mask on save', async () => { diff --git a/apps/signage-manager/src/tests/shared/playlist-request-approval-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/playlist-request-approval-modal.component.spec.ts index feff6130cbb..29724b95ae9 100644 --- a/apps/signage-manager/src/tests/shared/playlist-request-approval-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/playlist-request-approval-modal.component.spec.ts @@ -2,10 +2,7 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { setNotifyOutlet } from '@placeos/common'; -import { - listSignagePlaylistMediaRevisions, - updateSignagePlaylistMedia, -} from '@placeos/ts-client'; +import { listSignagePlaylistMediaRevisions } from '@placeos/ts-client'; import { MediaPreviewService } from '../../app/media/media-preview.service'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; import { PlaylistRequestApprovalModalComponent } from '../../app/shared/playlist-request-approval-modal.component'; @@ -34,7 +31,7 @@ describe('PlaylistRequestApprovalModalComponent', () => { changed: vi.fn(), }; const media_service = { previewMedia: vi.fn() }; - const playlist_service = { setPlaylistApprovalStatus: vi.fn() }; + const playlist_service = { undoPlaylistChanges: vi.fn() }; beforeEach(async () => { vi.clearAllMocks(); @@ -66,7 +63,6 @@ describe('PlaylistRequestApprovalModalComponent', () => { approved: true, }, ]); - (updateSignagePlaylistMedia as any).mockResolvedValue({}); await TestBed.configureTestingModule({ imports: [PlaylistRequestApprovalModalComponent], providers: [ @@ -222,40 +218,32 @@ describe('PlaylistRequestApprovalModalComponent', () => { expect(fixture.nativeElement.textContent).not.toContain('Undo Changes'); }); - it('does not undo changes when user does not have update permissions', async () => { - context.can_update.set(false); + it('restores the approved version through the service', async () => { + playlist_service.undoPlaylistChanges.mockResolvedValue(true); const fixture = TestBed.createComponent( PlaylistRequestApprovalModalComponent, ); const component = fixture.componentInstance; + fixture.detectChanges(); await component.undoChanges(); - expect(updateSignagePlaylistMedia).not.toHaveBeenCalled(); - expect(notify_open).toHaveBeenCalledWith( - 'You cannot update playlists in this group.', - expect.anything(), - expect.objectContaining({ panelClass: ['warn'] }), + expect(playlist_service.undoPlaylistChanges).toHaveBeenCalledWith( + { id: 'playlist-1', name: 'Lobby Loop' }, + expect.objectContaining({ id: 'previous-version' }), ); + expect(dialog_ref.close).toHaveBeenCalledWith(); }); - it('reverts playlist media to the previous version', async () => { + it('stays open when the undo is cancelled', async () => { + playlist_service.undoPlaylistChanges.mockResolvedValue(false); const fixture = TestBed.createComponent( PlaylistRequestApprovalModalComponent, ); - const component = fixture.componentInstance; fixture.detectChanges(); - await component.undoChanges(); + await fixture.componentInstance.undoChanges(); - expect(updateSignagePlaylistMedia).toHaveBeenCalledWith('playlist-1', [ - 'media-2', - ]); - expect(playlist_service.setPlaylistApprovalStatus).toHaveBeenCalledWith( - 'playlist-1', - false, - ); - expect(dialog_ref.close).toHaveBeenCalledWith(); - expect(context.changed).toHaveBeenCalled(); + expect(dialog_ref.close).not.toHaveBeenCalled(); }); }); diff --git a/apps/signage-manager/src/tests/shared/schedule-summary.util.spec.ts b/apps/signage-manager/src/tests/shared/schedule-summary.util.spec.ts new file mode 100644 index 00000000000..72af308a02f --- /dev/null +++ b/apps/signage-manager/src/tests/shared/schedule-summary.util.spec.ts @@ -0,0 +1,143 @@ +import { settingSignal } from '@placeos/common'; +import { getUnixTime } from 'date-fns'; +import { + parseRecurringCron, + scheduleExpiryLabel, + scheduleSummary, +} from '../../app/shared/schedule-summary.util'; +import { formatPlayDateTime } from '../../app/signage-playlist.util'; + +describe('schedule summary util', () => { + afterEach(() => settingSignal('use_24_hour_time', false).set(false)); + + // play_at arrives from the API as unix seconds, never milliseconds + it('summarises a play once schedule at its stored time', () => { + const play_at = new Date('2026-03-02T09:30:00'); + + expect( + scheduleSummary( + { play_at: getUnixTime(play_at), play_period: 30 }, + 'en-AU', + ), + ).toBe( + `Plays once on ${formatPlayDateTime(play_at, undefined, true)} for 30 minutes`, + ); + }); + + it('shows a play once time in the 24 hour format when the app uses it', () => { + settingSignal('use_24_hour_time', false).set(true); + const play_at = new Date('2026-03-02T21:00:00'); + + const summary = scheduleSummary( + { play_at: getUnixTime(play_at), play_period: 30 }, + 'en-AU', + ); + + expect(summary).toContain('21:00'); + expect(summary).not.toMatch(/PM/i); + }); + + it('notes the display timezone of a local play once schedule', () => { + const summary = scheduleSummary( + { + play_at_local: '2026-03-02T09:30:00', + // The API always sends a fallback cron with one-off schedules. + play_cron: '0 0 * * *', + play_period: 30, + }, + 'en-AU', + ); + + expect(summary).toContain( + `${formatPlayDateTime(new Date(2026, 2, 2, 9, 30), undefined, true)} display local time`, + ); + }); + + it('summarises recurring schedules with translated text', () => { + expect( + scheduleSummary({ play_cron: '0 9 * * *', play_period: 30 }, 'en'), + ).toBe('Starts every day at 9:00 AM for 30 minutes.'); + expect( + scheduleSummary( + { play_cron: '30 8 1,22 * *', play_period: 0 }, + 'en', + ), + ).toBe( + 'Starts on the 1st, 22nd of each month at 8:30 AM for one playlist pass.', + ); + }); + + it('shows times in the 24 hour format when the app uses it', () => { + settingSignal('use_24_hour_time', false).set(true); + + expect( + scheduleSummary({ play_cron: '0 21 * * *', play_period: 30 }, 'en'), + ).toBe('Starts every day at 21:00 for 30 minutes.'); + }); + + it('summarises schedules that repeat every few minutes', () => { + expect( + scheduleSummary({ play_cron: '*/5 * * * *', play_period: 1 }, 'en'), + ).toBe('Plays every 5 minutes.'); + expect( + scheduleSummary({ play_cron: '* * * * *', play_period: 1 }, 'en'), + ).toBe('Plays every 1 minute.'); + }); + + it('names weekdays in the given locale', () => { + expect( + scheduleSummary({ play_cron: '0 9 * * 1', play_period: 60 }, 'fr'), + ).toContain('lundi'); + }); + + it('shows the cron of a schedule the form cannot edit', () => { + expect( + scheduleSummary({ play_cron: '0 9 1 6 *', play_period: 1 }, 'en'), + ).toBe('Custom schedule (0 9 1 6 *)'); + }); + + it('adds the takeover, end and mask of a schedule', () => { + const summary = scheduleSummary( + { + play_cron: '0 9 * * *', + play_period: 30, + play_takeover: true, + valid_until: getUnixTime(Date.now() + 3 * 24 * 60 * 60 * 1000), + mask: '101', + }, + 'en', + ); + + expect(summary).toBe( + 'Starts every day at 9:00 AM for 30 minutes. · takeover · until 3 days from now · Mask 101, repeats every 3 instances', + ); + }); + + it('describes the end of a schedule relative to now', () => { + const now = Date.UTC(2026, 0, 1, 9); + + expect( + scheduleExpiryLabel( + getUnixTime(new Date(Date.UTC(2026, 0, 22, 9))), + now, + ), + ).toBe('until 21 days from now'); + expect( + scheduleExpiryLabel(getUnixTime(now - 2 * 60 * 60 * 1000), now), + ).toBe('until 2 hours ago'); + expect(scheduleExpiryLabel(undefined, now)).toBe(''); + }); + + it('reads the repeat pattern of a cron expression', () => { + expect(parseRecurringCron('0 9 8-14 * 1')).toMatchObject({ + recurrence_type: 'monthly_weekday', + recurrence_time: '09:00', + recurrence_week_of_month: [2], + recurrence_weekdays: [1], + }); + expect(parseRecurringCron('0 */3 * * *')).toMatchObject({ + recurrence_type: 'hours', + recurrence_interval: 3, + }); + }); +}); diff --git a/apps/signage-manager/src/tests/shared/template-mappings.component.spec.ts b/apps/signage-manager/src/tests/shared/template-mappings.component.spec.ts index 5ee7fdfe560..22156447eaf 100644 --- a/apps/signage-manager/src/tests/shared/template-mappings.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/template-mappings.component.spec.ts @@ -127,7 +127,9 @@ describe('TemplateMappingsComponent', () => { ); expect(zone_link?.textContent).toContain('First floor'); expect(zone_link?.textContent).not.toContain('zone-1'); - expect(fixture.nativeElement.textContent).toContain('Weekdays at'); + expect(fixture.nativeElement.textContent).toContain( + 'Starts every weekday at', + ); expect(fixture.nativeElement.textContent).toContain('for 30 minutes'); expect(fixture.nativeElement.querySelectorAll('button').length).toBe(2); expect(fixture.nativeElement.querySelector('button[error]')).toBeNull(); diff --git a/apps/signage-manager/src/tests/signage-playlist.util.spec.ts b/apps/signage-manager/src/tests/signage-playlist.util.spec.ts index 00552481b41..5d268d38287 100644 --- a/apps/signage-manager/src/tests/signage-playlist.util.spec.ts +++ b/apps/signage-manager/src/tests/signage-playlist.util.spec.ts @@ -10,9 +10,7 @@ import { playlistMediaItems, playlistNextPlayLabels, playlistNextPlayTime, - playlistScheduleExpiryLabel, playlistScheduleExpiryTooltip, - playlistScheduleLabel, playlistScheduleNextPlayLabels, playlistStatus, playOnceStart, @@ -77,33 +75,6 @@ describe('signage playlist util', () => { ).toBe(item); }); - // play_at arrives from the API as unix seconds, never milliseconds - it('labels a one-off schedule at its stored time', () => { - const play_at = new Date('2026-03-02T09:30:00'); - - const label = playlistScheduleLabel({ - play_at: getUnixTime(play_at), - play_period: 30, - }); - - expect(label).toContain('Plays once on'); - expect(label).toContain(play_at.toLocaleString()); - expect(label).toContain('for 30 minutes'); - }); - - it('labels a local one-off schedule in the viewer timezone', () => { - const label = playlistScheduleLabel({ - play_at_local: '2026-03-02T09:30:00', - // The API always sends a fallback cron with one-off schedules. - play_cron: '0 0 * * *', - play_period: 30, - }); - - expect(label).toContain( - `Plays once on ${new Date(2026, 2, 2, 9, 30).toLocaleString()} display local time`, - ); - }); - it('starts a local play once schedule at its date time', () => { expect(playOnceStart({ play_at_local: '2027-01-01T00:00:00' })).toEqual( new Date(2027, 0, 1), @@ -113,39 +84,12 @@ describe('signage playlist util', () => { ).toBeNull(); }); - it('labels cron schedules like playlist details', () => { - expect( - playlistScheduleLabel({ - play_cron: '0 9 * * *', - play_period: 30, - }), - ).toContain('Every day at'); - expect( - playlistScheduleLabel({ - play_cron: '0 9 * * *', - play_period: 30, - }), - ).toContain('for 30 minutes'); - }); - - it('adds relative and exact schedule expiry descriptions', () => { - const now = Date.UTC(2026, 0, 1, 9); + it('describes the exact end of a schedule', () => { const valid_until = getUnixTime(new Date(Date.UTC(2026, 0, 22, 9))); - const schedule = { valid_until }; - expect(playlistScheduleExpiryLabel(schedule, now)).toBe( - 'until 21 days from now', - ); - expect(playlistScheduleExpiryTooltip(schedule)).toBe( + expect(playlistScheduleExpiryTooltip({ valid_until })).toBe( new Date(valid_until * 1000).toLocaleString(), ); - expect( - playlistScheduleLabel({ - ...schedule, - play_cron: '0 9 * * *', - play_period: 30, - }), - ).toContain(' · until '); }); it('lists the next five play blocks for a recurring schedule', () => { diff --git a/apps/signage-manager/src/tests/signage-zone-search.spec.ts b/apps/signage-manager/src/tests/signage-zone-search.spec.ts index 8116bdfde82..cdf49ccb440 100644 --- a/apps/signage-manager/src/tests/signage-zone-search.spec.ts +++ b/apps/signage-manager/src/tests/signage-zone-search.spec.ts @@ -269,7 +269,7 @@ describe('SignageZoneService zone search', () => { TestBed.tick(); await flush(); - (service as any)._cacheZone( + (service as any)._assignments.cache( new PlaceZone({ id: 'level-1', display_name: 'Level 1' }), ); diff --git a/shared/assets/locale/ar.json b/shared/assets/locale/ar.json index 4026abecf96..7ede6342caa 100644 --- a/shared/assets/locale/ar.json +++ b/shared/assets/locale/ar.json @@ -2303,6 +2303,14 @@ "NO_ZONES_AVAILABLE": "لا توجد مناطق متاحة.", "NO_ZONES_USE_PLAYLIST": "لا توجد مناطق تستخدم قائمة التشغيل هذه.", "ONE_PLAYLIST_PASS": "دورة واحدة لقائمة التشغيل", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "فتح الشاشة {{ name }}", "OPEN_GROUP": "فتح المجموعة {{ name }}", "OPEN_PANEL": "فتح لوحة اللافتات", @@ -2337,6 +2345,7 @@ "PLAYLIST_NAME_ARIA": "اسم قائمة التشغيل", "PLAYLIST_ORIENTATION_ARIA": "اتجاه قائمة التشغيل", "PLAYLIST_REVERTED": "تمت إعادة قائمة التشغيل إلى الإصدار السابق", + "PLAYLIST_REVERT_CONFIRM": "هل تريد تجاهل التغييرات المعلقة واستعادة النسخة المعتمدة من {{ name }}؟ لا يمكنك استرجاع التغييرات المعلقة.", "PLAYLIST_REVERT_ERROR": "خطأ في التراجع عن تغييرات قائمة التشغيل", "PLAYLIST_SAVED": "تم حفظ قائمة التشغيل", "PLAYLIST_SAVE_ERROR": "خطأ في حفظ قائمة التشغيل", @@ -2421,6 +2430,8 @@ "SUMMARY_DAILY": "يبدأ كل يوم عند {{ time }} لمدة {{ duration }}.", "SUMMARY_EVERY_HOUR": "يعمل كل {{ interval }} ساعة.", "SUMMARY_EVERY_HOUR_1": "يعمل كل {{ interval }} ساعة.", + "SUMMARY_EVERY_MINUTE": "يعمل كل {{ interval }} دقيقة.", + "SUMMARY_EVERY_MINUTE_1": "يعمل كل {{ interval }} دقيقة.", "SUMMARY_MONTHLY": "يبدأ في {{ days }} من كل شهر عند {{ time }} لمدة {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "يبدأ في {{ weeks }} {{ days }} من كل شهر عند {{ time }} لمدة {{ duration }}.", "SUMMARY_PLAY_ONCE": "يعمل مرة واحدة في {{ datetime }} لمدة {{ duration }}", @@ -2533,6 +2544,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} منطقة", "ZONE_DETAILS_TABS": "علامات تبويب تفاصيل المنطقة", "ZONE_PERMISSIONS": "أذونات المنطقة", - "ZONE_SELECT_DETAILS": "حدد منطقة لعرض تفاصيلها." + "ZONE_SELECT_DETAILS": "حدد منطقة لعرض تفاصيلها.", + "SVC_REMOVING": "جارٍ الإزالة...", + "SCHEDULE_UNTIL_FROM_NOW": "ينتهي بعد {{ time }}", + "SCHEDULE_UNTIL_AGO": "انتهى منذ {{ time }}" } } diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 61ddf686eab..22281f45f02 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -440,6 +440,14 @@ "NO_ZONES_AVAILABLE": "No zones available.", "NO_ZONES_USE_PLAYLIST": "No zones use this playlist.", "ONE_PLAYLIST_PASS": "one playlist pass", + "ORDINAL": "{{ count }}th", + "ORDINAL_1": "{{ count }}st", + "ORDINAL_2": "{{ count }}nd", + "ORDINAL_3": "{{ count }}rd", + "ORDINAL_21": "{{ count }}st", + "ORDINAL_22": "{{ count }}nd", + "ORDINAL_23": "{{ count }}rd", + "ORDINAL_31": "{{ count }}st", "OPEN_DISPLAY": "Open display {{ name }}", "OPEN_GROUP": "Open group {{ name }}", "OPEN_PANEL": "Open signage panel", @@ -482,6 +490,7 @@ "PLAYLIST_NOT_FOUND": "Could not open the playlist. It may have been deleted.", "PLAYLIST_ORIENTATION_ARIA": "Playlist orientation", "PLAYLIST_REVERTED": "Playlist reverted to previous version", + "PLAYLIST_REVERT_CONFIRM": "Discard the pending changes and restore the approved version of {{ name }}? You cannot get the pending changes back.", "PLAYLIST_REVERT_ERROR": "Error reverting playlist changes", "PLAYLIST_SAVED": "Playlist saved", "PLAYLIST_SAVE_ERROR": "Error saving playlist", @@ -593,6 +602,8 @@ "SUMMARY_DAILY": "Starts every day at {{ time }} for {{ duration }}.", "SUMMARY_EVERY_HOUR": "Plays every {{ interval }} hours.", "SUMMARY_EVERY_HOUR_1": "Plays every {{ interval }} hour.", + "SUMMARY_EVERY_MINUTE": "Plays every {{ interval }} minutes.", + "SUMMARY_EVERY_MINUTE_1": "Plays every {{ interval }} minute.", "SUMMARY_MONTHLY": "Starts on the {{ days }} of each month at {{ time }} for {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Starts on the {{ weeks }} {{ days }} of each month at {{ time }} for {{ duration }}.", "DISPLAY_LOCAL_TIME": "display local time", @@ -649,6 +660,7 @@ "SVC_MEDIA_PLAYLISTS_NOT_UPDATED": "The media was removed, but some playlists could not be updated.", "SVC_MEDIA_REMOVED": "Media removed", "SVC_MEDIA_REMOVING": "Removing media...", + "SVC_REMOVING": "Removing...", "SVC_MEDIA_TAG_ERROR": "Unable to update tag: {{ error }}", "SVC_MEDIA_TAG_REMOVED": "Tag removed", "SVC_MEDIA_TAG_RENAMED": "Tag renamed", @@ -840,6 +852,8 @@ "SCHEDULE_MASK": "Repeat mask", "SCHEDULE_MASK_HINT": "Select each occurrence to switch between Play and Skip. Occurrence 1 starts at or after Valid From. The pattern then repeats.", "SCHEDULE_MASK_SUMMARY": "Mask {{ mask }}, repeats every {{ size }} instances", + "SCHEDULE_UNTIL_FROM_NOW": "until {{ time }} from now", + "SCHEDULE_UNTIL_AGO": "until {{ time }} ago", "MASK_REPEAT_LENGTH": "Repeat every", "MASK_OCCURRENCES": "{{ count }} occurrences", "MASK_OCCURRENCES_1": "{{ count }} occurrence", diff --git a/shared/assets/locale/en-GB.json b/shared/assets/locale/en-GB.json index 55b6ebe6767..e2749ba8775 100644 --- a/shared/assets/locale/en-GB.json +++ b/shared/assets/locale/en-GB.json @@ -2617,6 +2617,14 @@ "NO_ZONES_AVAILABLE": "No zones available.", "NO_ZONES_USE_PLAYLIST": "No zones use this playlist.", "ONE_PLAYLIST_PASS": "one playlist pass", + "ORDINAL": "{{ count }}th", + "ORDINAL_1": "{{ count }}st", + "ORDINAL_2": "{{ count }}nd", + "ORDINAL_3": "{{ count }}rd", + "ORDINAL_21": "{{ count }}st", + "ORDINAL_22": "{{ count }}nd", + "ORDINAL_23": "{{ count }}rd", + "ORDINAL_31": "{{ count }}st", "OPEN_DISPLAY": "Open display {{ name }}", "OPEN_GROUP": "Open group {{ name }}", "OPEN_PANEL": "Open signage panel", @@ -2658,6 +2666,7 @@ "PLAYLIST_NOT_FOUND": "Could not open the playlist. It may have been deleted.", "PLAYLIST_ORIENTATION_ARIA": "Playlist orientation", "PLAYLIST_REVERTED": "Playlist reverted to previous version", + "PLAYLIST_REVERT_CONFIRM": "Discard the pending changes and restore the approved version of {{ name }}? You cannot get the pending changes back.", "PLAYLIST_REVERT_ERROR": "Error reverting playlist changes", "PLAYLIST_SAVED": "Playlist saved", "PLAYLIST_SAVE_ERROR": "Error saving playlist", @@ -2759,6 +2768,8 @@ "SUMMARY_DAILY": "Starts every day at {{ time }} for {{ duration }}.", "SUMMARY_EVERY_HOUR": "Plays every {{ interval }} hours.", "SUMMARY_EVERY_HOUR_1": "Plays every {{ interval }} hour.", + "SUMMARY_EVERY_MINUTE": "Plays every {{ interval }} minutes.", + "SUMMARY_EVERY_MINUTE_1": "Plays every {{ interval }} minute.", "SUMMARY_MONTHLY": "Starts on the {{ days }} of each month at {{ time }} for {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Starts on the {{ weeks }} {{ days }} of each month at {{ time }} for {{ duration }}.", "SUMMARY_PLAY_ONCE": "Plays once on {{ datetime }} for {{ duration }}", @@ -2812,6 +2823,7 @@ "SVC_MEDIA_PLAYLISTS_NOT_UPDATED": "The media was removed, but some playlists could not be updated.", "SVC_MEDIA_REMOVED": "Media removed", "SVC_MEDIA_REMOVING": "Removing media...", + "SVC_REMOVING": "Removing...", "SVC_MEDIA_SHARED": "Media shared", "SVC_NO_APPROVERS": "Unable to load playlist approvers.", "SVC_NO_APPROVE_PLAYLISTS": "You cannot approve playlists in this group.", @@ -2972,6 +2984,8 @@ "ZONE_DETAILS_TABS": "Zone details tabs", "ZONE_PERMISSIONS": "Zone permissions", "ZONE_SELECT_DETAILS": "Select a zone to view its details.", - "BRAND_DISABLED": "Branding changes are turned off." + "BRAND_DISABLED": "Branding changes are turned off.", + "SCHEDULE_UNTIL_FROM_NOW": "until {{ time }} from now", + "SCHEDULE_UNTIL_AGO": "until {{ time }} ago" } } diff --git a/shared/assets/locale/en-US.json b/shared/assets/locale/en-US.json index 844c93e5ab1..67fe55b6a87 100644 --- a/shared/assets/locale/en-US.json +++ b/shared/assets/locale/en-US.json @@ -2639,6 +2639,14 @@ "NO_ZONES_AVAILABLE": "No zones available.", "NO_ZONES_USE_PLAYLIST": "No zones use this playlist.", "ONE_PLAYLIST_PASS": "one playlist pass", + "ORDINAL": "{{ count }}th", + "ORDINAL_1": "{{ count }}st", + "ORDINAL_2": "{{ count }}nd", + "ORDINAL_3": "{{ count }}rd", + "ORDINAL_21": "{{ count }}st", + "ORDINAL_22": "{{ count }}nd", + "ORDINAL_23": "{{ count }}rd", + "ORDINAL_31": "{{ count }}st", "OPEN_DISPLAY": "Open display {{ name }}", "OPEN_GROUP": "Open group {{ name }}", "OPEN_PANEL": "Open signage panel", @@ -2681,6 +2689,7 @@ "PLAYLIST_NOT_FOUND": "Could not open the playlist. It may have been deleted.", "PLAYLIST_ORIENTATION_ARIA": "Playlist orientation", "PLAYLIST_REVERTED": "Playlist reverted to previous version", + "PLAYLIST_REVERT_CONFIRM": "Discard the pending changes and restore the approved version of {{ name }}? You cannot get the pending changes back.", "PLAYLIST_REVERT_ERROR": "Error reverting playlist changes", "PLAYLIST_SAVED": "Playlist saved", "PLAYLIST_SAVE_ERROR": "Error saving playlist", @@ -2788,6 +2797,8 @@ "SUMMARY_DAILY": "Starts every day at {{ time }} for {{ duration }}.", "SUMMARY_EVERY_HOUR": "Plays every {{ interval }} hours.", "SUMMARY_EVERY_HOUR_1": "Plays every {{ interval }} hour.", + "SUMMARY_EVERY_MINUTE": "Plays every {{ interval }} minutes.", + "SUMMARY_EVERY_MINUTE_1": "Plays every {{ interval }} minute.", "SUMMARY_MONTHLY": "Starts on the {{ days }} of each month at {{ time }} for {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Starts on the {{ weeks }} {{ days }} of each month at {{ time }} for {{ duration }}.", "SUMMARY_PLAY_ONCE": "Plays once on {{ datetime }} for {{ duration }}", @@ -2843,6 +2854,7 @@ "SVC_MEDIA_PLAYLISTS_NOT_UPDATED": "The media was removed, but some playlists could not be updated.", "SVC_MEDIA_REMOVED": "Media removed", "SVC_MEDIA_REMOVING": "Removing media...", + "SVC_REMOVING": "Removing...", "SVC_MEDIA_TAG_ERROR": "Unable to update tag: {{ error }}", "SVC_MEDIA_TAG_REMOVED": "Tag removed", "SVC_MEDIA_TAG_RENAMED": "Tag renamed", @@ -3028,6 +3040,8 @@ "ZONE_PERMISSIONS": "Zone permissions", "ZONE_SAVING": "Saving zone...", "ZONE_SELECT_DETAILS": "Select a zone to view its details.", - "BRAND_DISABLED": "Branding changes are turned off." + "BRAND_DISABLED": "Branding changes are turned off.", + "SCHEDULE_UNTIL_FROM_NOW": "until {{ time }} from now", + "SCHEDULE_UNTIL_AGO": "until {{ time }} ago" } } diff --git a/shared/assets/locale/es.json b/shared/assets/locale/es.json index 83bdb338437..31ee0b172df 100644 --- a/shared/assets/locale/es.json +++ b/shared/assets/locale/es.json @@ -2303,6 +2303,14 @@ "NO_ZONES_AVAILABLE": "No hay zonas disponibles.", "NO_ZONES_USE_PLAYLIST": "Ninguna zona usa esta lista de reproducción.", "ONE_PLAYLIST_PASS": "una pasada de la lista de reproducción", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "Abrir pantalla {{ name }}", "OPEN_GROUP": "Abrir grupo {{ name }}", "OPEN_PANEL": "Abrir panel de señalización", @@ -2337,6 +2345,7 @@ "PLAYLIST_NAME_ARIA": "Nombre de la lista de reproducción", "PLAYLIST_ORIENTATION_ARIA": "Orientación de la lista de reproducción", "PLAYLIST_REVERTED": "Lista de reproducción revertida a la versión anterior", + "PLAYLIST_REVERT_CONFIRM": "¿Descartar los cambios pendientes y restaurar la versión aprobada de {{ name }}? No podrá recuperar los cambios pendientes.", "PLAYLIST_REVERT_ERROR": "Error al revertir los cambios de la lista de reproducción", "PLAYLIST_SAVED": "Lista de reproducción guardada", "PLAYLIST_SAVE_ERROR": "Error al guardar la lista de reproducción", @@ -2421,6 +2430,8 @@ "SUMMARY_DAILY": "Empieza todos los días a las {{ time }} durante {{ duration }}.", "SUMMARY_EVERY_HOUR": "Se reproduce cada {{ interval }} horas.", "SUMMARY_EVERY_HOUR_1": "Se reproduce cada {{ interval }} hora.", + "SUMMARY_EVERY_MINUTE": "Se reproduce cada {{ interval }} minutos.", + "SUMMARY_EVERY_MINUTE_1": "Se reproduce cada {{ interval }} minuto.", "SUMMARY_MONTHLY": "Empieza el día {{ days }} de cada mes a las {{ time }} durante {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Empieza el {{ weeks }} {{ days }} de cada mes a las {{ time }} durante {{ duration }}.", "SUMMARY_PLAY_ONCE": "Se reproduce una vez el {{ datetime }} durante {{ duration }}", @@ -2533,6 +2544,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} zona", "ZONE_DETAILS_TABS": "Pestañas de detalles de la zona", "ZONE_PERMISSIONS": "Permisos de zona", - "ZONE_SELECT_DETAILS": "Selecciona una zona para ver sus detalles." + "ZONE_SELECT_DETAILS": "Selecciona una zona para ver sus detalles.", + "SVC_REMOVING": "Eliminando...", + "SCHEDULE_UNTIL_FROM_NOW": "termina dentro de {{ time }}", + "SCHEDULE_UNTIL_AGO": "terminó hace {{ time }}" } } diff --git a/shared/assets/locale/fr-CA.json b/shared/assets/locale/fr-CA.json index 946ef331aeb..c6fb9e202a8 100644 --- a/shared/assets/locale/fr-CA.json +++ b/shared/assets/locale/fr-CA.json @@ -2303,6 +2303,14 @@ "NO_ZONES_AVAILABLE": "Aucune zone disponible.", "NO_ZONES_USE_PLAYLIST": "Aucune zone n'utilise cette liste de lecture.", "ONE_PLAYLIST_PASS": "un passage de la liste de lecture", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}er", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "Ouvrir l'écran {{ name }}", "OPEN_GROUP": "Ouvrir le groupe {{ name }}", "OPEN_PANEL": "Ouvrir le panneau d'affichage", @@ -2337,6 +2345,7 @@ "PLAYLIST_NAME_ARIA": "Nom de la liste de lecture", "PLAYLIST_ORIENTATION_ARIA": "Orientation de la liste de lecture", "PLAYLIST_REVERTED": "Liste de lecture rétablie à la version précédente", + "PLAYLIST_REVERT_CONFIRM": "Abandonner les modifications en attente et restaurer la version approuvée de {{ name }} ? Vous ne pourrez pas récupérer les modifications en attente.", "PLAYLIST_REVERT_ERROR": "Erreur lors du rétablissement des changements de la liste de lecture", "PLAYLIST_SAVED": "Liste de lecture enregistrée", "PLAYLIST_SAVE_ERROR": "Erreur lors de l'enregistrement de la liste de lecture", @@ -2421,6 +2430,8 @@ "SUMMARY_DAILY": "Commence tous les jours à {{ time }} pendant {{ duration }}.", "SUMMARY_EVERY_HOUR": "Se lit toutes les {{ interval }} heures.", "SUMMARY_EVERY_HOUR_1": "Se lit toutes les {{ interval }} heure.", + "SUMMARY_EVERY_MINUTE": "Se lit toutes les {{ interval }} minutes.", + "SUMMARY_EVERY_MINUTE_1": "Se lit toutes les {{ interval }} minute.", "SUMMARY_MONTHLY": "Commence le {{ days }} de chaque mois à {{ time }} pendant {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Commence le {{ weeks }} {{ days }} de chaque mois à {{ time }} pendant {{ duration }}.", "SUMMARY_PLAY_ONCE": "Se lit une fois le {{ datetime }} pendant {{ duration }}", @@ -2533,6 +2544,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} zone", "ZONE_DETAILS_TABS": "Onglets des détails de la zone", "ZONE_PERMISSIONS": "Autorisations de la zone", - "ZONE_SELECT_DETAILS": "Sélectionnez une zone pour afficher ses détails." + "ZONE_SELECT_DETAILS": "Sélectionnez une zone pour afficher ses détails.", + "SVC_REMOVING": "Suppression...", + "SCHEDULE_UNTIL_FROM_NOW": "se termine dans {{ time }}", + "SCHEDULE_UNTIL_AGO": "terminé il y a {{ time }}" } } diff --git a/shared/assets/locale/fr.json b/shared/assets/locale/fr.json index 3fd1defc4bd..a3b863370ad 100644 --- a/shared/assets/locale/fr.json +++ b/shared/assets/locale/fr.json @@ -2303,6 +2303,14 @@ "NO_ZONES_AVAILABLE": "Aucune zone disponible.", "NO_ZONES_USE_PLAYLIST": "Aucune zone n'utilise cette liste de lecture.", "ONE_PLAYLIST_PASS": "un passage de la liste de lecture", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}er", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "Ouvrir l'écran {{ name }}", "OPEN_GROUP": "Ouvrir le groupe {{ name }}", "OPEN_PANEL": "Ouvrir le panneau d'affichage", @@ -2337,6 +2345,7 @@ "PLAYLIST_NAME_ARIA": "Nom de la liste de lecture", "PLAYLIST_ORIENTATION_ARIA": "Orientation de la liste de lecture", "PLAYLIST_REVERTED": "Liste de lecture rétablie à la version précédente", + "PLAYLIST_REVERT_CONFIRM": "Abandonner les modifications en attente et restaurer la version approuvée de {{ name }} ? Vous ne pourrez pas récupérer les modifications en attente.", "PLAYLIST_REVERT_ERROR": "Erreur lors du rétablissement des changements de la liste de lecture", "PLAYLIST_SAVED": "Liste de lecture enregistrée", "PLAYLIST_SAVE_ERROR": "Erreur lors de l'enregistrement de la liste de lecture", @@ -2421,6 +2430,8 @@ "SUMMARY_DAILY": "Commence tous les jours à {{ time }} pendant {{ duration }}.", "SUMMARY_EVERY_HOUR": "Se lit toutes les {{ interval }} heures.", "SUMMARY_EVERY_HOUR_1": "Se lit toutes les {{ interval }} heure.", + "SUMMARY_EVERY_MINUTE": "Se lit toutes les {{ interval }} minutes.", + "SUMMARY_EVERY_MINUTE_1": "Se lit toutes les {{ interval }} minute.", "SUMMARY_MONTHLY": "Commence le {{ days }} de chaque mois à {{ time }} pendant {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Commence le {{ weeks }} {{ days }} de chaque mois à {{ time }} pendant {{ duration }}.", "SUMMARY_PLAY_ONCE": "Se lit une fois le {{ datetime }} pendant {{ duration }}", @@ -2533,6 +2544,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} zone", "ZONE_DETAILS_TABS": "Onglets des détails de la zone", "ZONE_PERMISSIONS": "Autorisations de la zone", - "ZONE_SELECT_DETAILS": "Sélectionnez une zone pour afficher ses détails." + "ZONE_SELECT_DETAILS": "Sélectionnez une zone pour afficher ses détails.", + "SVC_REMOVING": "Suppression...", + "SCHEDULE_UNTIL_FROM_NOW": "se termine dans {{ time }}", + "SCHEDULE_UNTIL_AGO": "terminé il y a {{ time }}" } } diff --git a/shared/assets/locale/it.json b/shared/assets/locale/it.json index d582c479d4e..9dde8706f20 100644 --- a/shared/assets/locale/it.json +++ b/shared/assets/locale/it.json @@ -2303,6 +2303,14 @@ "NO_ZONES_AVAILABLE": "Nessuna zona disponibile.", "NO_ZONES_USE_PLAYLIST": "Nessuna zona usa questa lista di riproduzione.", "ONE_PLAYLIST_PASS": "un passaggio della lista di riproduzione", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "Apri schermo {{ name }}", "OPEN_GROUP": "Apri gruppo {{ name }}", "OPEN_PANEL": "Apri pannello di segnaletica", @@ -2337,6 +2345,7 @@ "PLAYLIST_NAME_ARIA": "Nome lista di riproduzione", "PLAYLIST_ORIENTATION_ARIA": "Orientamento lista di riproduzione", "PLAYLIST_REVERTED": "Lista di riproduzione ripristinata alla versione precedente", + "PLAYLIST_REVERT_CONFIRM": "Annullare le modifiche in sospeso e ripristinare la versione approvata di {{ name }}? Non sarà possibile recuperare le modifiche in sospeso.", "PLAYLIST_REVERT_ERROR": "Errore durante il ripristino delle modifiche alla lista di riproduzione", "PLAYLIST_SAVED": "Lista di riproduzione salvata", "PLAYLIST_SAVE_ERROR": "Errore durante il salvataggio della lista di riproduzione", @@ -2421,6 +2430,8 @@ "SUMMARY_DAILY": "Inizia ogni giorno alle {{ time }} per {{ duration }}.", "SUMMARY_EVERY_HOUR": "Riproduce ogni {{ interval }} ore.", "SUMMARY_EVERY_HOUR_1": "Riproduce ogni {{ interval }} ora.", + "SUMMARY_EVERY_MINUTE": "Riproduce ogni {{ interval }} minuti.", + "SUMMARY_EVERY_MINUTE_1": "Riproduce ogni {{ interval }} minuto.", "SUMMARY_MONTHLY": "Inizia il {{ days }} di ogni mese alle {{ time }} per {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Inizia il {{ weeks }} {{ days }} di ogni mese alle {{ time }} per {{ duration }}.", "SUMMARY_PLAY_ONCE": "Riproduce una volta il {{ datetime }} per {{ duration }}", @@ -2533,6 +2544,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} zona", "ZONE_DETAILS_TABS": "Schede dettagli zona", "ZONE_PERMISSIONS": "Autorizzazioni zona", - "ZONE_SELECT_DETAILS": "Seleziona una zona per visualizzarne i dettagli." + "ZONE_SELECT_DETAILS": "Seleziona una zona per visualizzarne i dettagli.", + "SVC_REMOVING": "Rimozione in corso...", + "SCHEDULE_UNTIL_FROM_NOW": "termina tra {{ time }}", + "SCHEDULE_UNTIL_AGO": "terminato {{ time }} fa" } } diff --git a/shared/assets/locale/ja-JP.json b/shared/assets/locale/ja-JP.json index 3e2e855fdbd..e17a7a79902 100644 --- a/shared/assets/locale/ja-JP.json +++ b/shared/assets/locale/ja-JP.json @@ -2303,6 +2303,14 @@ "NO_ZONES_AVAILABLE": "利用可能なゾーンがありません。", "NO_ZONES_USE_PLAYLIST": "このプレイリストを使用しているゾーンはありません。", "ONE_PLAYLIST_PASS": "プレイリストを 1 周", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "ディスプレイ {{ name }} を開く", "OPEN_GROUP": "グループ {{ name }} を開く", "OPEN_PANEL": "サイネージパネルを開く", @@ -2337,6 +2345,7 @@ "PLAYLIST_NAME_ARIA": "プレイリスト名", "PLAYLIST_ORIENTATION_ARIA": "プレイリストの向き", "PLAYLIST_REVERTED": "プレイリストを前のバージョンに戻しました", + "PLAYLIST_REVERT_CONFIRM": "保留中の変更を破棄し、{{ name }} の承認済みバージョンに戻しますか?保留中の変更は元に戻せません。", "PLAYLIST_REVERT_ERROR": "プレイリストの変更を戻す際にエラーが発生しました", "PLAYLIST_SAVED": "プレイリストを保存しました", "PLAYLIST_SAVE_ERROR": "プレイリストの保存中にエラーが発生しました", @@ -2421,6 +2430,8 @@ "SUMMARY_DAILY": "毎日 {{ time }} に開始し、{{ duration }} 継続します。", "SUMMARY_EVERY_HOUR": "{{ interval }} 時間ごとに再生します。", "SUMMARY_EVERY_HOUR_1": "{{ interval }} 時間ごとに再生します。", + "SUMMARY_EVERY_MINUTE": "{{ interval }} 分ごとに再生します。", + "SUMMARY_EVERY_MINUTE_1": "{{ interval }} 分ごとに再生します。", "SUMMARY_MONTHLY": "毎月 {{ days }} 日の {{ time }} に開始し、{{ duration }} 継続します。", "SUMMARY_MONTHLY_WEEKDAY": "毎月第 {{ weeks }} {{ days }} の {{ time }} に開始し、{{ duration }} 継続します。", "SUMMARY_PLAY_ONCE": "{{ datetime }} に 1 回再生し、{{ duration }} 継続します", @@ -2533,6 +2544,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} 件のゾーン", "ZONE_DETAILS_TABS": "ゾーン詳細タブ", "ZONE_PERMISSIONS": "ゾーン権限", - "ZONE_SELECT_DETAILS": "詳細を表示するゾーンを選択してください。" + "ZONE_SELECT_DETAILS": "詳細を表示するゾーンを選択してください。", + "SVC_REMOVING": "削除しています...", + "SCHEDULE_UNTIL_FROM_NOW": "{{ time }}後まで", + "SCHEDULE_UNTIL_AGO": "{{ time }}前に終了" } } diff --git a/shared/assets/locale/pt.json b/shared/assets/locale/pt.json index 0537404462a..a125deccf57 100644 --- a/shared/assets/locale/pt.json +++ b/shared/assets/locale/pt.json @@ -2303,6 +2303,14 @@ "NO_ZONES_AVAILABLE": "Nenhuma zona disponível.", "NO_ZONES_USE_PLAYLIST": "Nenhuma zona usa esta lista de reprodução.", "ONE_PLAYLIST_PASS": "uma passagem da lista de reprodução", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "Abrir ecrã {{ name }}", "OPEN_GROUP": "Abrir grupo {{ name }}", "OPEN_PANEL": "Abrir painel de sinalização", @@ -2337,6 +2345,7 @@ "PLAYLIST_NAME_ARIA": "Nome da lista de reprodução", "PLAYLIST_ORIENTATION_ARIA": "Orientação da lista de reprodução", "PLAYLIST_REVERTED": "Lista de reprodução revertida para a versão anterior", + "PLAYLIST_REVERT_CONFIRM": "Descartar as alterações pendentes e repor a versão aprovada de {{ name }}? Não poderá recuperar as alterações pendentes.", "PLAYLIST_REVERT_ERROR": "Erro ao reverter alterações da lista de reprodução", "PLAYLIST_SAVED": "Lista de reprodução guardada", "PLAYLIST_SAVE_ERROR": "Erro ao guardar lista de reprodução", @@ -2421,6 +2430,8 @@ "SUMMARY_DAILY": "Começa todos os dias às {{ time }} durante {{ duration }}.", "SUMMARY_EVERY_HOUR": "Reproduz a cada {{ interval }} horas.", "SUMMARY_EVERY_HOUR_1": "Reproduz a cada {{ interval }} hora.", + "SUMMARY_EVERY_MINUTE": "Reproduz a cada {{ interval }} minutos.", + "SUMMARY_EVERY_MINUTE_1": "Reproduz a cada {{ interval }} minuto.", "SUMMARY_MONTHLY": "Começa no dia {{ days }} de cada mês às {{ time }} durante {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Começa no {{ weeks }} {{ days }} de cada mês às {{ time }} durante {{ duration }}.", "SUMMARY_PLAY_ONCE": "Reproduz uma vez em {{ datetime }} durante {{ duration }}", @@ -2533,6 +2544,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} zona", "ZONE_DETAILS_TABS": "Separadores de detalhes da zona", "ZONE_PERMISSIONS": "Permissões da zona", - "ZONE_SELECT_DETAILS": "Selecione uma zona para ver os detalhes." + "ZONE_SELECT_DETAILS": "Selecione uma zona para ver os detalhes.", + "SVC_REMOVING": "A remover...", + "SCHEDULE_UNTIL_FROM_NOW": "termina daqui a {{ time }}", + "SCHEDULE_UNTIL_AGO": "terminou há {{ time }}" } } diff --git a/shared/assets/locale/zh.json b/shared/assets/locale/zh.json index c80eee3b668..eea73b47814 100644 --- a/shared/assets/locale/zh.json +++ b/shared/assets/locale/zh.json @@ -2304,6 +2304,14 @@ "NO_ZONES_AVAILABLE": "没有可用区域。", "NO_ZONES_USE_PLAYLIST": "没有区域使用此播放列表。", "ONE_PLAYLIST_PASS": "一次播放列表轮播", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "打开显示屏 {{ name }}", "OPEN_GROUP": "打开组 {{ name }}", "OPEN_PANEL": "打开数字标牌面板", @@ -2338,6 +2346,7 @@ "PLAYLIST_NAME_ARIA": "播放列表名称", "PLAYLIST_ORIENTATION_ARIA": "播放列表方向", "PLAYLIST_REVERTED": "播放列表已恢复到上一版本", + "PLAYLIST_REVERT_CONFIRM": "放弃待处理的更改并恢复 {{ name }} 的已批准版本?待处理的更改将无法找回。", "PLAYLIST_REVERT_ERROR": "恢复播放列表变更时出错", "PLAYLIST_SAVED": "播放列表已保存", "PLAYLIST_SAVE_ERROR": "保存播放列表时出错", @@ -2422,6 +2431,8 @@ "SUMMARY_DAILY": "每天 {{ time }} 开始,持续 {{ duration }}。", "SUMMARY_EVERY_HOUR": "每 {{ interval }} 小时播放一次。", "SUMMARY_EVERY_HOUR_1": "每 {{ interval }} 小时播放一次。", + "SUMMARY_EVERY_MINUTE": "每 {{ interval }} 分钟播放一次。", + "SUMMARY_EVERY_MINUTE_1": "每 {{ interval }} 分钟播放一次。", "SUMMARY_MONTHLY": "每月 {{ days }} 日 {{ time }} 开始,持续 {{ duration }}。", "SUMMARY_MONTHLY_WEEKDAY": "每月第 {{ weeks }} 个 {{ days }} 的 {{ time }} 开始,持续 {{ duration }}。", "SUMMARY_PLAY_ONCE": "在 {{ datetime }} 播放一次,持续 {{ duration }}", @@ -2534,6 +2545,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} 个区域", "ZONE_DETAILS_TABS": "区域详情标签", "ZONE_PERMISSIONS": "区域权限", - "ZONE_SELECT_DETAILS": "选择区域以查看其详情。" + "ZONE_SELECT_DETAILS": "选择区域以查看其详情。", + "SVC_REMOVING": "正在删除...", + "SCHEDULE_UNTIL_FROM_NOW": "{{ time }}后结束", + "SCHEDULE_UNTIL_AGO": "已于 {{ time }}前结束" } } From bd7983befb7ea84af2fc0828eba823c0c977370f Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 20:24:58 +1100 Subject: [PATCH 20/21] refactor(stagehand,map-studio,public): share page, geometry and helpers - Stagehand: one page layout, one URL filter handler, one nav list. - Map Studio: one bookable check, bounds helper, image loader and validation on the objects in memory. - Public: shared query helpers; registration and save failures no longer leave unhandled rejections. --- .../src/app/data/floorplan-ai.service.ts | 17 +- apps/map-studio/src/app/data/load-image.ts | 23 + .../src/app/data/placeos.service.ts | 35 +- apps/map-studio/src/app/data/store.service.ts | 111 +-- apps/map-studio/src/app/data/svg-import.ts | 41 +- apps/map-studio/src/app/data/thumbnail.ts | 9 +- .../map-studio/src/app/editor/availability.ts | 18 +- .../src/app/editor/canvas.component.ts | 140 ++-- apps/map-studio/src/app/editor/constants.ts | 15 + .../src/app/editor/editor-state.service.ts | 50 +- .../src/app/editor/editor.component.ts | 4 +- apps/map-studio/src/app/editor/geometry.ts | 24 + .../src/app/editor/minimap.component.ts | 14 +- .../app/editor/object-list-panel.component.ts | 14 +- .../src/app/editor/publish-panel.component.ts | 6 +- apps/map-studio/src/app/editor/source-svg.ts | 33 +- apps/map-studio/src/app/editor/svg-export.ts | 70 +- .../app/editor/validation-panel.component.ts | 53 +- .../app/kiosk/availability-polling.service.ts | 7 +- .../src/app/kiosk/floor-plan-2d.component.ts | 21 +- apps/map-studio/src/app/kiosk/heatmap.ts | 6 +- .../src/app/kiosk/kiosk.component.ts | 6 +- .../src/app/pages/import.component.ts | 14 +- .../src/tests/store.service.spec.ts | 26 - apps/map-studio/src/tests/svg-export.spec.ts | 23 +- apps/public/src/app/public-event.helpers.ts | 28 + .../public/src/app/public-events.component.ts | 75 +- apps/public/src/app/public-events.service.ts | 17 +- .../src/app/public-guest-details.component.ts | 51 +- .../src/app/public-page-shell.component.ts | 21 + .../src/tests/public-event.helpers.spec.ts | 23 + .../src/tests/public-events.component.spec.ts | 10 + .../src/tests/public-events.service.spec.ts | 13 + .../tests/public-page-shell.component.spec.ts | 13 + apps/stagehand/src/app/alerts.component.ts | 666 ++++++++--------- apps/stagehand/src/app/analytics.component.ts | 97 +-- .../alert-condition-compare-form.component.ts | 2 +- .../alert-conditions-modal.component.ts | 6 +- .../dashboard-alert-list.component.ts | 8 +- .../dashboard-alert-manage.component.ts | 10 +- .../dashboards/dashboard-list.component.ts | 8 +- .../dashboards/dashboard-mange.component.ts | 8 +- .../app/dashboards/dashboards.component.ts | 100 ++- .../src/app/dashboards/dashboards.service.ts | 61 +- .../system-search-field.component.ts | 2 +- .../src/app/recorder-view.component.ts | 32 +- .../src/app/remote-support.component.ts | 702 ++++++++---------- .../stagehand/src/app/ui/sidebar.component.ts | 150 ++-- .../src/app/ui/stagehand-page.component.ts | 38 + ...t-condition-compare-form.component.spec.ts | 2 +- .../src/tests/alerts.component.spec.ts | 62 +- .../src/tests/dashboards.service.spec.ts | 57 +- .../dashboard-alert-list.component.spec.ts | 3 +- .../dashboard-alert-manage.component.spec.ts | 10 +- .../dashboard-list.component.spec.ts | 3 +- .../dashboard-mange.component.spec.ts | 5 +- .../dashboards/dashboards.component.spec.ts | 2 +- .../tests/remote-support.component.spec.ts | 22 +- 58 files changed, 1436 insertions(+), 1651 deletions(-) create mode 100644 apps/map-studio/src/app/data/load-image.ts create mode 100644 apps/stagehand/src/app/ui/stagehand-page.component.ts diff --git a/apps/map-studio/src/app/data/floorplan-ai.service.ts b/apps/map-studio/src/app/data/floorplan-ai.service.ts index ef7b124c030..91aa3fb93e6 100644 --- a/apps/map-studio/src/app/data/floorplan-ai.service.ts +++ b/apps/map-studio/src/app/data/floorplan-ai.service.ts @@ -1,5 +1,6 @@ import { Injectable, inject } from '@angular/core'; +import { loadImage } from './load-image'; import { PlaceOSService } from './placeos.service'; import { StoreService } from './store.service'; @@ -142,22 +143,6 @@ async function prepareImage(source: Blob): Promise<{ return { base64: await blobBase64(jpeg), width, height }; } -function loadImage(source: Blob): Promise { - const url = URL.createObjectURL(source); - return new Promise((resolve, reject) => { - const image = new Image(); - image.onload = () => { - URL.revokeObjectURL(url); - resolve(image); - }; - image.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error('Could not read source image')); - }; - image.src = url; - }); -} - function drawCoordinateGrid( context: CanvasRenderingContext2D, width: number, diff --git a/apps/map-studio/src/app/data/load-image.ts b/apps/map-studio/src/app/data/load-image.ts new file mode 100644 index 00000000000..aa074f9e1db --- /dev/null +++ b/apps/map-studio/src/app/data/load-image.ts @@ -0,0 +1,23 @@ +/** + * Loads an image from a URL or a blob. A blob's object URL is revoked once + * the image loads or fails. Rejects when the image cannot be read. + */ +export function loadImage(source: string | Blob): Promise { + const url = + typeof source === 'string' ? source : URL.createObjectURL(source); + const release = () => { + if (url !== source) URL.revokeObjectURL(url); + }; + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + release(); + resolve(image); + }; + image.onerror = () => { + release(); + reject(new Error('Could not read source image')); + }; + image.src = url; + }); +} diff --git a/apps/map-studio/src/app/data/placeos.service.ts b/apps/map-studio/src/app/data/placeos.service.ts index 32d40f1fac2..f69b55514c0 100644 --- a/apps/map-studio/src/app/data/placeos.service.ts +++ b/apps/map-studio/src/app/data/placeos.service.ts @@ -1,5 +1,5 @@ import { Injectable, signal } from '@angular/core'; -import { requestTokenFromOpener } from '@placeos/common'; +import { requestTokenFromOpener, withTimeout } from '@placeos/common'; import { authorise, authority, @@ -39,25 +39,6 @@ export function withoutLocalFields( return data; } -function withDeadline(promise: Promise, message: string): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error(message)), - PLACEOS_REQUEST_TIMEOUT, - ); - promise.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (error) => { - clearTimeout(timer); - reject(error); - }, - ); - }); -} - function fetchWithDeadline( input: RequestInfo | URL, init: RequestInit, @@ -67,9 +48,11 @@ function fetchWithDeadline( const request = fetch(input, { ...init, signal: controller.signal }).then( read, ); - return withDeadline(request, 'PlaceOS request timed out.').finally(() => - controller.abort(), - ); + return withTimeout( + request, + PLACEOS_REQUEST_TIMEOUT, + 'PlaceOS request timed out.', + ).finally(() => controller.abort()); } export interface PlaceOSSettings { @@ -229,7 +212,11 @@ export class PlaceOSService { handle_login: false, }; try { - await withDeadline(setup(options), 'PlaceOS setup timed out.'); + await withTimeout( + setup(options), + PLACEOS_REQUEST_TIMEOUT, + 'PlaceOS setup timed out.', + ); if (!token()) await this._useOpenerToken(); options.handle_login = true; // With handle_login on, this redirects to the login page diff --git a/apps/map-studio/src/app/data/store.service.ts b/apps/map-studio/src/app/data/store.service.ts index b50f5daa872..0506c2c2e6b 100644 --- a/apps/map-studio/src/app/data/store.service.ts +++ b/apps/map-studio/src/app/data/store.service.ts @@ -14,7 +14,6 @@ import { MapObject, MapObjectType, Project, - ValidationIssue, } from './types'; type ZoneReference = Pick; @@ -713,26 +712,28 @@ export class StoreService { for (const [index, parsed] of analysis.objects.entries()) { const mapping = mappings?.get(parsed.svgId); if (mappings && !mapping) continue; + const fill = + parsed.presentation?.fill || parsed.attributes['fill'] || null; + const stroke = + parsed.presentation?.stroke || + parsed.attributes['stroke'] || + null; + const opacity = + parsed.presentation?.opacity ?? + Number(parsed.attributes['opacity'] ?? 1); + const label = + mapping?.label || + parsed.label || + (parsed.tag === 'text' ? '' : parsed.svgId); objects.push({ object_type: (mapping?.objectType ?? parsed.suggestedType) as MapObjectType, svg_id: parsed.svgId, - label: - mapping?.label || - parsed.label || - (parsed.tag === 'text' ? '' : parsed.svgId), + label, geometry: parsed.geometry, - fill_color: - parsed.presentation?.fill || - parsed.attributes['fill'] || - null, - stroke_color: - parsed.presentation?.stroke || - parsed.attributes['stroke'] || - null, - opacity: - parsed.presentation?.opacity ?? - Number(parsed.attributes['opacity'] ?? 1), + fill_color: fill, + stroke_color: stroke, + opacity, layer: parsed.layer, group_id: parsed.groupId, z_index: index, @@ -741,21 +742,10 @@ export class StoreService { originalTag: parsed.tag, originalAttributes: parsed.attributes, originalGeometry: parsed.geometry, - originalFill: - parsed.presentation?.fill || - parsed.attributes['fill'] || - null, - originalStroke: - parsed.presentation?.stroke || - parsed.attributes['stroke'] || - null, - originalOpacity: - parsed.presentation?.opacity ?? - Number(parsed.attributes['opacity'] ?? 1), - originalLabel: - mapping?.label || - parsed.label || - (parsed.tag === 'text' ? '' : parsed.svgId), + originalFill: fill, + originalStroke: stroke, + originalOpacity: opacity, + originalLabel: label, parentMatrix: parsed.parentMatrix, }, }); @@ -821,22 +811,6 @@ export class StoreService { }); } - public async updateObject( - floorplan_id: string, - id: string, - data: Partial, - ): Promise { - const object = await this._db.get( - key.object(floorplan_id, id), - ); - if (!object) throw new Error('Object not found'); - await this._db.set(key.object(floorplan_id, id), { - ...object, - ...data, - id, - }); - } - /** * Writes `objects` and removes `remove_ids` in one transaction. Returns the * objects as stored, with defaults filled in. @@ -853,47 +827,4 @@ export class StoreService { }); return records.map(([, object]) => object); } - - // ── Validation ────────────────────────────────────────────────────────── - - public async validateFloorplan( - floorplan_id: string, - ): Promise { - const objects = await this.listObjects(floorplan_id); - const issues: ValidationIssue[] = []; - const seen = new Set(); - const duplicates = new Set(); - - for (const obj of objects) { - if (!obj.label) { - issues.push({ - type: 'warning', - objectId: obj.id, - message: `Object ${obj.id} (${obj.object_type}) has no label`, - }); - } - if (obj.svg_id) { - if (seen.has(obj.svg_id)) duplicates.add(obj.svg_id); - seen.add(obj.svg_id); - } - if (!obj.geometry || Object.keys(obj.geometry).length === 0) { - issues.push({ - type: 'error', - objectId: obj.id, - message: `Object ${obj.id} (${obj.object_type}) has empty geometry`, - }); - } - } - - for (const svg_id of duplicates) { - for (const obj of objects.filter((o) => o.svg_id === svg_id)) { - issues.push({ - type: 'error', - objectId: obj.id, - message: `Duplicate svg_id "${svg_id}" on object ${obj.id}`, - }); - } - } - return issues; - } } diff --git a/apps/map-studio/src/app/data/svg-import.ts b/apps/map-studio/src/app/data/svg-import.ts index 0f20feed93d..56c21b0e7bc 100644 --- a/apps/map-studio/src/app/data/svg-import.ts +++ b/apps/map-studio/src/app/data/svg-import.ts @@ -1,7 +1,9 @@ import { invert, multiply, + pointsIn, sanitiseTree, + transformBBox, translation, } from '@placeos/map-editor'; import { MapObjectType, ObjectGeometry } from './types'; @@ -137,17 +139,6 @@ function parseNumber(value: string | null): number | undefined { return isNaN(num) ? undefined : num; } -function parsePoints(points: string): { x: number; y: number }[] { - const parts = points.trim().split(/[\s,]+/); - const parsed: { x: number; y: number }[] = []; - for (let i = 0; i < parts.length - 1; i += 2) { - const x = parseFloat(parts[i]); - const y = parseFloat(parts[i + 1]); - if (!isNaN(x) && !isNaN(y)) parsed.push({ x, y }); - } - return parsed; -} - function extractGeometry(el: Element): ObjectGeometry { switch (el.tagName.toLowerCase()) { case 'rect': @@ -181,7 +172,7 @@ function extractGeometry(el: Element): ObjectGeometry { const points = el.getAttribute('points'); return { type: 'polygon', - points: points ? parsePoints(points) : [], + points: pointsIn(points).map(([x, y]) => ({ x, y })), }; } case 'path': @@ -379,25 +370,13 @@ export function analyzeSvg(svg_content: string): SvgAnalysis { f: pm.f, }; if (matrix && (box.width || box.height)) { - const corners = [ - [box.x, box.y], - [box.x + box.width, box.y], - [box.x, box.y + box.height], - [box.x + box.width, box.y + box.height], - ]; - const xs = corners.map( - ([x, y]) => matrix.a * x + matrix.c * y + matrix.e, - ); - const ys = corners.map( - ([x, y]) => matrix.b * x + matrix.d * y + matrix.f, - ); - geometry = { - type: 'rect', - x: Math.min(...xs), - y: Math.min(...ys), - width: Math.max(...xs) - Math.min(...xs), - height: Math.max(...ys) - Math.min(...ys), - }; + const [x, y, width, height] = transformBBox(matrix, [ + box.x, + box.y, + box.width, + box.height, + ]); + geometry = { type: 'rect', x, y, width, height }; } } catch { /* Non-rendering SVG nodes have no bounding box. */ diff --git a/apps/map-studio/src/app/data/thumbnail.ts b/apps/map-studio/src/app/data/thumbnail.ts index d950c06e652..884985a8c78 100644 --- a/apps/map-studio/src/app/data/thumbnail.ts +++ b/apps/map-studio/src/app/data/thumbnail.ts @@ -1,3 +1,5 @@ +import { loadImage } from './load-image'; + /** Longest edge of a stored preview, in pixels */ const THUMBNAIL_SIZE = 480; @@ -7,11 +9,8 @@ const THUMBNAIL_SIZE = 480; * to the original image if it cannot be drawn. */ export async function makeThumbnail(image: Blob): Promise { - const url = URL.createObjectURL(image); try { - const element = new Image(); - element.src = url; - await element.decode(); + const element = await loadImage(image); const width = element.naturalWidth; const height = element.naturalHeight; if (!width || !height) return image; @@ -28,7 +27,5 @@ export async function makeThumbnail(image: Blob): Promise { return thumbnail ?? image; } catch { return image; - } finally { - URL.revokeObjectURL(url); } } diff --git a/apps/map-studio/src/app/editor/availability.ts b/apps/map-studio/src/app/editor/availability.ts index 34957e8ca0a..215f4342650 100644 --- a/apps/map-studio/src/app/editor/availability.ts +++ b/apps/map-studio/src/app/editor/availability.ts @@ -1,4 +1,20 @@ -import { AvailabilityState } from '../data/types'; +import { AvailabilityState, MapObject } from '../data/types'; + +/** Whether PlaceOS can bind a booking to the object: rooms and desks */ +export const isBookable = (object: Pick) => + object.object_type === 'room' || object.object_type === 'desk'; + +/** + * Converts a `#rrggbb` colour into rgba() at the given opacity. A missing `#` + * is allowed, and a part that is not hex reads as 0. + */ +export function toFillOpacity(hex: string, opacity: number): string { + const clean = hex.replace('#', '').slice(0, 6); + const r = parseInt(clean.slice(0, 2), 16) || 0; + const g = parseInt(clean.slice(2, 4), 16) || 0; + const b = parseInt(clean.slice(4, 6), 16) || 0; + return `rgba(${r},${g},${b},${opacity})`; +} /** Colour each availability state paints objects in preview mode */ export const STATE_COLORS: Record = { diff --git a/apps/map-studio/src/app/editor/canvas.component.ts b/apps/map-studio/src/app/editor/canvas.component.ts index 786543da424..77a52eab0df 100644 --- a/apps/map-studio/src/app/editor/canvas.component.ts +++ b/apps/map-studio/src/app/editor/canvas.component.ts @@ -12,7 +12,7 @@ import { import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { MapObject } from '../data/types'; -import { getAvailabilityColor } from './availability'; +import { getAvailabilityColor, isBookable } from './availability'; import { AMENITY_ICONS, FURNITURE_ASSETS, @@ -30,12 +30,22 @@ import { closestPointOnSegment, computeSnap, getHandlePos, + objectBounds, pointInRotatedRect, rotatePoint, } from './geometry'; import { composeSourceSvg, importedObject } from './source-svg'; +import { sequentialId } from './svg-export'; import { ToastService } from './toast.service'; +/** Position of each resize handle on the given bounding box */ +function handlePositions(x: number, y: number, w: number, h: number) { + return HANDLES.map((handle) => { + const [hx, hy] = getHandlePos(handle, x, y, w, h); + return { handle, x: hx, y: hy }; + }); +} + interface DragState { id: string; offsetX: number; @@ -310,17 +320,17 @@ const clamp01 = (value: number) => Math.max(0, Math.min(1, value)); object.id === state.selected_id() && state.multi_select().length < 2 ) { - @for (handle of handles; track handle) { + @for (pos of selected_handles(); track pos.handle) { } Math.max(0, Math.min(1, value)); stroke-dasharray="6 3" class="pointer-events-none" /> - @for (handle of handles; track handle) { + @for (pos of group_handles(); track pos.handle) { } Math.max(0, Math.min(1, value)); state.floorplan()?.svg_source && !object.locked ) { - @for (handle of handles; track handle) { + @for (pos of selected_handles(); track pos.handle) { } >('renameInput'); - public readonly handles = HANDLES; - public readonly rect_draw = signal(null); public readonly selection_box = signal(null); public readonly outline_points = signal([]); @@ -849,8 +857,7 @@ export class CanvasComponent { /** Move cursor over objects the select tool can drag, else inherit */ public moveCursor(object: MapObject) { const cycles = - this.state.availability_enabled() && - (object.object_type === 'room' || object.object_type === 'desk'); + this.state.availability_enabled() && isBookable(object); return this._can_move() && !cycles && !object.locked && @@ -970,6 +977,25 @@ export class CanvasComponent { }; }); + /** Resize handle positions around the selected object */ + public readonly selected_handles = computed(() => { + const geometry = this.state.selected()?.geometry; + if (!geometry) return []; + return handlePositions( + geometry.x ?? 0, + geometry.y ?? 0, + geometry.width ?? 50, + geometry.height ?? 50, + ); + }); + + /** Resize handle positions around the multi-selection */ + public readonly group_handles = computed(() => { + const group = this.groupBounds(); + if (!group) return []; + return handlePositions(group.x, group.y, group.width, group.height); + }); + public readonly groupBounds = computed(() => { const ids = this.state.multi_select_ids(); if (ids.size < 2) return null; @@ -977,7 +1003,7 @@ export class CanvasComponent { .objects() .filter((object) => ids.has(object.id)); if (objects.length < 2) return null; - const bounds = objects.map((object) => this._objectBounds(object)); + const bounds = objects.map((object) => objectBounds(object.geometry)); const x = Math.min(...bounds.map((item) => item.x)); const y = Math.min(...bounds.map((item) => item.y)); const right = Math.max(...bounds.map((item) => item.x + item.width)); @@ -1098,50 +1124,10 @@ export class CanvasComponent { return (object.geometry.y ?? 0) - 25 / (this.state.stroke_width() / 2); } - public handleX(object: MapObject, handle: Handle) { - return getHandlePos( - handle, - object.geometry.x ?? 0, - object.geometry.y ?? 0, - object.geometry.width ?? 50, - object.geometry.height ?? 50, - )[0]; - } - - public handleY(object: MapObject, handle: Handle) { - return getHandlePos( - handle, - object.geometry.x ?? 0, - object.geometry.y ?? 0, - object.geometry.width ?? 50, - object.geometry.height ?? 50, - )[1]; - } - public cursorFor(handle: Handle) { return HANDLE_CURSORS[handle]; } - public groupHandleX(group: GroupTransformState['bounds'], handle: Handle) { - return getHandlePos( - handle, - group.x, - group.y, - group.width, - group.height, - )[0]; - } - - public groupHandleY(group: GroupTransformState['bounds'], handle: Handle) { - return getHandlePos( - handle, - group.x, - group.y, - group.width, - group.height, - )[1]; - } - public groupRotateY(group: GroupTransformState['bounds']) { return group.y - 25 / (this.state.stroke_width() / 2); } @@ -1449,10 +1435,7 @@ export class CanvasComponent { const hit = this._hitTest(x, y); if (hit) { // In preview mode a click cycles the availability state instead - if ( - this.state.availability_enabled() && - (hit.object_type === 'room' || hit.object_type === 'desk') - ) { + if (this.state.availability_enabled() && isBookable(hit)) { this.state.select(hit.id); this.state.cycleAvailability(hit); event.preventDefault(); @@ -1995,7 +1978,7 @@ export class CanvasComponent { geometry.points = geometry.points.map(transform); Object.assign(geometry, boundsOf(geometry.points)); } else { - const bounds = this._objectBounds(object); + const bounds = objectBounds(object.geometry); const center = transform({ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2, @@ -2053,7 +2036,7 @@ export class CanvasComponent { .visible_objects() .filter((object) => { if (object.layer !== activeLayer || object.locked) return false; - const bounds = this._objectBounds(object); + const bounds = objectBounds(object.geometry); return ( bounds.x >= left && bounds.y >= top && @@ -2073,26 +2056,6 @@ export class CanvasComponent { .map((object) => structuredClone(object)); } - private _objectBounds(object: MapObject) { - const geometry = object.geometry; - if (geometry.points?.length) return boundsOf(geometry.points); - if (geometry.type === 'circle') { - const r = geometry.r ?? 12; - return { - x: (geometry.x ?? 0) - r, - y: (geometry.y ?? 0) - r, - width: r * 2, - height: r * 2, - }; - } - return { - x: geometry.x ?? 0, - y: geometry.y ?? 0, - width: geometry.width ?? 0, - height: geometry.height ?? 0, - }; - } - private _applyVertexDrag( vertex: { id: string; idx: number }, x: number, @@ -2149,12 +2112,7 @@ export class CanvasComponent { /** Topmost selectable object under the cursor, preferring the active layer */ private _hitTest(x: number, y: number): MapObject | null { - const selectable = new Set( - this.state - .layers() - .filter((l) => l.visible && !l.locked) - .map((l) => l.id), - ); + const selectable = this._pickable_layers(); const active = this.state.active_layer_id(); const hits: { object: MapObject; area: number; onActive: boolean }[] = []; @@ -2330,7 +2288,7 @@ export class CanvasComponent { { object_type: 'decorative', label: `Wall ${count + 1}`, - svg_id: `wall-${String(count + 1).padStart(3, '0')}`, + svg_id: sequentialId('wall-', count + 1), geometry: { type: 'polygon', points, ...boundsOf(points) }, layer: 'walls', fill_color: '#374151', diff --git a/apps/map-studio/src/app/editor/constants.ts b/apps/map-studio/src/app/editor/constants.ts index eb232ad52b0..91d8a94588d 100644 --- a/apps/map-studio/src/app/editor/constants.ts +++ b/apps/map-studio/src/app/editor/constants.ts @@ -36,6 +36,21 @@ export const TYPE_COLORS: Record = { locker: '#9333ea', }; +/** + * Type colours of the object list and the minimap. They are not the canvas + * colours in `TYPE_COLORS`. + */ +export const TYPE_ACCENT_COLORS: Record = { + room: '#3b82f6', + desk: '#22c55e', + zone: '#a855f7', + area: '#f59e0b', + amenity: '#06b6d4', + decorative: '#6b7280', + parking: '#8b5cf6', + locker: '#ec4899', +}; + export const OBJECT_TYPES: MapObjectType[] = [ 'room', 'desk', diff --git a/apps/map-studio/src/app/editor/editor-state.service.ts b/apps/map-studio/src/app/editor/editor-state.service.ts index 7ea136a84bc..0bb3ea8d884 100644 --- a/apps/map-studio/src/app/editor/editor-state.service.ts +++ b/apps/map-studio/src/app/editor/editor-state.service.ts @@ -3,6 +3,7 @@ import { Injectable, computed, inject, signal } from '@angular/core'; import { AsyncHandler, errorMessage } from '@placeos/common'; import type { DetectedRoom } from '../data/floorplan-ai.service'; +import { loadImage } from '../data/load-image'; import { PlaceOSService } from '../data/placeos.service'; import { StoreService } from '../data/store.service'; import { @@ -13,7 +14,12 @@ import { MapObject, MapObjectType, } from '../data/types'; -import { cycleState, defaultStateFor, getStatesForType } from './availability'; +import { + cycleState, + defaultStateFor, + getStatesForType, + isBookable, +} from './availability'; import { AMENITY_ICONS, DESK_LAYOUTS, @@ -474,19 +480,10 @@ export class EditorStateService extends AsyncHandler { this._image_dims.set(null); return; } - const dims = await new Promise<{ - width: number; - height: number; - } | null>((resolve) => { - const img = new Image(); - img.onload = () => - resolve({ - width: img.naturalWidth, - height: img.naturalHeight, - }); - img.onerror = () => resolve(null); - img.src = url; - }); + const dims = await loadImage(url).then( + (img) => ({ width: img.naturalWidth, height: img.naturalHeight }), + () => null, + ); this._image_dims.set(dims); await this._storeImageSize(floorplan_id, dims); } @@ -642,7 +639,7 @@ export class EditorStateService extends AsyncHandler { const name = type.charAt(0).toUpperCase() + type.slice(1); return { label: `${name} ${count + 1}`, - svg_id: `${type}-${String(count + 1).padStart(3, '0')}`, + svg_id: sequentialId(`${type}-`, count + 1), }; } @@ -751,19 +748,14 @@ export class EditorStateService extends AsyncHandler { public async updateObject(id: string, updates: Partial) { this.patchLocal(id, updates); this.markDirty(); - await this._store - .updateObject(this._floorplan_id, id, updates) - .catch(() => undefined); + await this._persist([id]); } /** Persists whatever geometry the object currently holds after a drag */ public async commitGeometry(id: string) { - const object = this._objects().find((o) => o.id === id); - if (!object) return; + if (!this._objects().some((o) => o.id === id)) return; this.markDirty(); - await this._store - .updateObject(this._floorplan_id, id, { geometry: object.geometry }) - .catch(() => undefined); + await this._persist([id]); } public async deleteObject(id: string) { @@ -863,7 +855,7 @@ export class EditorStateService extends AsyncHandler { this.patchLocal(existing.id, values); this._selected_id.set(existing.id); this.markDirty(); - await this._store.updateObject(this._floorplan_id, existing.id, values); + await this._persist([existing.id]); return { ...existing, ...values } as MapObject; } @@ -955,7 +947,7 @@ export class EditorStateService extends AsyncHandler { await this.createObject({ object_type: 'amenity', label: asset?.label ?? 'Amenity', - svg_id: `amenity-${String(count + 1).padStart(3, '0')}`, + svg_id: sequentialId('amenity-', count + 1), geometry: { type: 'circle', x, y, r: 12 }, layer: PLACE_LAYERS.amenity, fill_color: '#dc2626', @@ -973,7 +965,7 @@ export class EditorStateService extends AsyncHandler { await this.createObject({ object_type: 'decorative', label: asset.label, - svg_id: `furniture-${String(count + 1).padStart(3, '0')}`, + svg_id: sequentialId('furniture-', count + 1), geometry: { type: 'rect', x: x - asset.w / 2, @@ -1011,7 +1003,7 @@ export class EditorStateService extends AsyncHandler { desks.push({ object_type: 'desk', label: `Desk ${index}`, - svg_id: `desk-${String(index).padStart(3, '0')}`, + svg_id: sequentialId('desk-', index), geometry: { type: 'rect', x: start_x + col * (layout.deskW + layout.gap), @@ -1076,9 +1068,7 @@ export class EditorStateService extends AsyncHandler { } public readonly bookable = computed(() => - this._objects().filter( - (o) => o.object_type === 'room' || o.object_type === 'desk', - ), + this._objects().filter(isBookable), ); // ── Multi-select ──────────────────────────────────────────────────────── diff --git a/apps/map-studio/src/app/editor/editor.component.ts b/apps/map-studio/src/app/editor/editor.component.ts index 1354a1cca84..15949fc9c13 100644 --- a/apps/map-studio/src/app/editor/editor.component.ts +++ b/apps/map-studio/src/app/editor/editor.component.ts @@ -17,6 +17,7 @@ import type { DetectedRoom } from '../data/floorplan-ai.service'; import { FloorplanAiService } from '../data/floorplan-ai.service'; import { MapObject } from '../data/types'; import { AssetPickerComponent } from './asset-picker.component'; +import { isBookable } from './availability'; import { AvailabilityPanelComponent } from './availability-panel.component'; import { CanvasComponent } from './canvas.component'; import { @@ -951,8 +952,7 @@ export class EditorComponent { .objects() .filter( (object) => - (object.object_type === 'room' || - object.object_type === 'desk') && + isBookable(object) && object.label?.toLowerCase().includes(query), ) .slice(0, 8); diff --git a/apps/map-studio/src/app/editor/geometry.ts b/apps/map-studio/src/app/editor/geometry.ts index ab78a678126..898d90140f7 100644 --- a/apps/map-studio/src/app/editor/geometry.ts +++ b/apps/map-studio/src/app/editor/geometry.ts @@ -1,3 +1,4 @@ +import { ObjectGeometry } from '../data/types'; import { Handle } from './constants'; export interface Point { @@ -194,6 +195,29 @@ export function boundsOf(points: Point[]) { }; } +/** + * Bounding box of an object's geometry, in geometry field order. A circle + * with no radius gets the 12 unit radius the canvas draws it with. + */ +export function objectBounds(geometry: ObjectGeometry) { + if (geometry.points?.length) return boundsOf(geometry.points); + if (geometry.type === 'circle') { + const r = geometry.r ?? 12; + return { + x: (geometry.x ?? 0) - r, + y: (geometry.y ?? 0) - r, + width: r * 2, + height: r * 2, + }; + } + return { + x: geometry.x ?? 0, + y: geometry.y ?? 0, + width: geometry.width ?? 0, + height: geometry.height ?? 0, + }; +} + /** * Aligns a dragged rectangle to the edges and centres of its neighbours. * Returns the snapped origin plus the guides that should be drawn. diff --git a/apps/map-studio/src/app/editor/minimap.component.ts b/apps/map-studio/src/app/editor/minimap.component.ts index abe20f4a9cc..6c9073d1db4 100644 --- a/apps/map-studio/src/app/editor/minimap.component.ts +++ b/apps/map-studio/src/app/editor/minimap.component.ts @@ -13,20 +13,10 @@ import { import { IconComponent } from '@placeos/components'; import { MapObject } from '../data/types'; +import { TYPE_ACCENT_COLORS } from './constants'; import { EditorStateService } from './editor-state.service'; import { importedObject } from './source-svg'; -const TYPE_COLORS: Record = { - room: '#3b82f6', - desk: '#22c55e', - zone: '#a855f7', - area: '#f59e0b', - amenity: '#06b6d4', - decorative: '#6b7280', - parking: '#8b5cf6', - locker: '#ec4899', -}; - /** Length of the minimap's longer side, so tall plans do not cover the canvas */ const MINIMAP_SIZE = 180; @@ -243,7 +233,7 @@ export class MinimapComponent { }); public readonly colorFor = (object: MapObject) => - TYPE_COLORS[object.object_type] ?? '#94a3b8'; + TYPE_ACCENT_COLORS[object.object_type] ?? '#94a3b8'; public readonly pointsOf = (object: MapObject) => (object.geometry.points ?? []).map((p) => `${p.x},${p.y}`).join(' '); diff --git a/apps/map-studio/src/app/editor/object-list-panel.component.ts b/apps/map-studio/src/app/editor/object-list-panel.component.ts index 0a156f94cfa..65482cb6508 100644 --- a/apps/map-studio/src/app/editor/object-list-panel.component.ts +++ b/apps/map-studio/src/app/editor/object-list-panel.component.ts @@ -2,19 +2,9 @@ import { Component, computed, inject, output, signal } from '@angular/core'; import { IconComponent } from '@placeos/components'; import { MapObject } from '../data/types'; +import { TYPE_ACCENT_COLORS } from './constants'; import { EditorStateService } from './editor-state.service'; -const TYPE_COLORS: Record = { - room: '#3b82f6', - desk: '#22c55e', - zone: '#a855f7', - area: '#f59e0b', - amenity: '#06b6d4', - decorative: '#6b7280', - parking: '#8b5cf6', - locker: '#ec4899', -}; - const TYPE_ORDER = [ 'room', 'desk', @@ -158,7 +148,7 @@ export class ObjectListPanelComponent { public readonly asValue = (event: Event) => (event.target as HTMLInputElement).value; - public readonly colorFor = (type: string) => TYPE_COLORS[type] ?? '#6b7280'; + public readonly colorFor = (type: string) => TYPE_ACCENT_COLORS[type] ?? '#6b7280'; public readonly plural = (type: string) => type === 'amenity' ? 'amenities' : `${type}s`; diff --git a/apps/map-studio/src/app/editor/publish-panel.component.ts b/apps/map-studio/src/app/editor/publish-panel.component.ts index 5333510720e..9fc3efbebbc 100644 --- a/apps/map-studio/src/app/editor/publish-panel.component.ts +++ b/apps/map-studio/src/app/editor/publish-panel.component.ts @@ -16,7 +16,7 @@ import { import { StoreService } from '../data/store.service'; import { Floorplan, MapObject } from '../data/types'; import { EditorStateService } from './editor-state.service'; -import { bookableObjects, buildSvg, preflightChecks } from './svg-export'; +import { buildSvg, preflightChecks } from './svg-export'; import { ToastService } from './toast.service'; /** A PlaceOS list request and its result */ @@ -481,9 +481,7 @@ export class PublishPanelComponent { this.checks().every((check) => check.pass), ); - public readonly bookable = computed(() => - bookableObjects(this.state.objects()), - ); + public readonly bookable = this.state.bookable; public readonly bookableSystems = computed(() => this.systems().items.filter((system) => system.bookable), diff --git a/apps/map-studio/src/app/editor/source-svg.ts b/apps/map-studio/src/app/editor/source-svg.ts index 2a3bf781673..f810cbe7be7 100644 --- a/apps/map-studio/src/app/editor/source-svg.ts +++ b/apps/map-studio/src/app/editor/source-svg.ts @@ -7,6 +7,7 @@ import { sanitiseTree, } from '@placeos/map-editor'; import { MapObject, ObjectGeometry } from '../data/types'; +import { objectBounds } from './geometry'; export function importedObject(object: MapObject): boolean { return object.metadata?.['source'] === 'svg-import'; @@ -39,34 +40,6 @@ function selectorPattern(id: string, selector_only = false) { ); } -function bounds(geometry: ObjectGeometry) { - if (geometry.points?.length) { - const xs = geometry.points.map((p) => p.x); - const ys = geometry.points.map((p) => p.y); - return { - x: Math.min(...xs), - y: Math.min(...ys), - width: Math.max(...xs) - Math.min(...xs), - height: Math.max(...ys) - Math.min(...ys), - }; - } - if (geometry.type === 'circle') { - const r = geometry.r || 0; - return { - x: (geometry.x || 0) - r, - y: (geometry.y || 0) - r, - width: r * 2, - height: r * 2, - }; - } - return { - x: geometry.x || 0, - y: geometry.y || 0, - width: geometry.width || 0, - height: geometry.height || 0, - }; -} - function patchObject(element: Element, object: MapObject) { const metadata = object.metadata || {}; const original = metadata['originalGeometry'] as ObjectGeometry | undefined; @@ -74,8 +47,8 @@ function patchObject(element: Element, object: MapObject) { original && JSON.stringify(original) !== JSON.stringify(object.geometry) ) { - const before = bounds(original); - const after = bounds(object.geometry); + const before = objectBounds(original); + const after = objectBounds(object.geometry); const sx = before.width ? after.width / before.width : 1; const sy = before.height ? after.height / before.height : 1; let delta: Matrix = { diff --git a/apps/map-studio/src/app/editor/svg-export.ts b/apps/map-studio/src/app/editor/svg-export.ts index 1fbca7e1e86..bcc9ab72b62 100644 --- a/apps/map-studio/src/app/editor/svg-export.ts +++ b/apps/map-studio/src/app/editor/svg-export.ts @@ -1,4 +1,5 @@ -import { MapObject, ObjectGeometry } from '../data/types'; +import { MapObject, ObjectGeometry, ValidationIssue } from '../data/types'; +import { isBookable, toFillOpacity } from './availability'; import { TYPE_COLORS } from './constants'; import { composeSourceSvg, importedObject } from './source-svg'; @@ -42,22 +43,60 @@ export function sequentialIdRange( : `${first} … ${sequentialId(prefix, start + count - 1)}`; } -/** Rooms and desks — the objects PlaceOS can bind a booking to */ -export const bookableObjects = (objects: MapObject[]) => - objects.filter((o) => o.object_type === 'room' || o.object_type === 'desk'); +/** How many times each id occurs */ +function idCounts(ids: string[]): Map { + const counts = new Map(); + for (const id of ids) counts.set(id, (counts.get(id) ?? 0) + 1); + return counts; +} + +/** + * Problems in the editor's objects: a missing label (warning), empty + * geometry, or an svg id that more than one object uses (errors). + */ +export function validateObjects( + objects: readonly MapObject[], +): ValidationIssue[] { + const issues: ValidationIssue[] = []; + for (const object of objects) { + if (!object.label) { + issues.push({ + type: 'warning', + objectId: object.id, + message: `Object ${object.id} (${object.object_type}) has no label`, + }); + } + if (!object.geometry || Object.keys(object.geometry).length === 0) { + issues.push({ + type: 'error', + objectId: object.id, + message: `Object ${object.id} (${object.object_type}) has empty geometry`, + }); + } + } + const svg_ids = idCounts( + objects.filter((o) => o.svg_id).map((o) => o.svg_id), + ); + for (const [svg_id, count] of svg_ids) { + if (count < 2) continue; + for (const object of objects.filter((o) => o.svg_id === svg_id)) { + issues.push({ + type: 'error', + objectId: object.id, + message: `Duplicate svg_id "${svg_id}" on object ${object.id}`, + }); + } + } + return issues; +} /** * Checks that must pass before a map can be published to PlaceOS. * Mirrors the reference application's pre-publish checklist. */ export function preflightChecks(objects: MapObject[]): PreflightCheck[] { - const bookable = bookableObjects(objects); - - const duplicates = new Map(); - for (const object of bookable) { - const id = idOf(object); - duplicates.set(id, (duplicates.get(id) ?? 0) + 1); - } + const bookable = objects.filter(isBookable); + const duplicates = idCounts(bookable.map(idOf)); return [ { @@ -100,15 +139,6 @@ export function preflightChecks(objects: MapObject[]): PreflightCheck[] { ]; } -/** Convert a hex colour into rgba() at the given opacity */ -function toFillOpacity(hex: string, opacity: number): string { - const clean = hex.replace('#', '').slice(0, 6); - const r = parseInt(clean.slice(0, 2), 16) || 0; - const g = parseInt(clean.slice(2, 4), 16) || 0; - const b = parseInt(clean.slice(4, 6), 16) || 0; - return `rgba(${r},${g},${b},${opacity})`; -} - function exportFill(object: MapObject): string { const base = TYPE_COLORS[object.object_type] ?? '#4b5563'; const color = object.fill_color || base; diff --git a/apps/map-studio/src/app/editor/validation-panel.component.ts b/apps/map-studio/src/app/editor/validation-panel.component.ts index 4303caca9c3..d90c35bbe1d 100644 --- a/apps/map-studio/src/app/editor/validation-panel.component.ts +++ b/apps/map-studio/src/app/editor/validation-panel.component.ts @@ -1,11 +1,13 @@ import { Component, computed, inject, output, signal } from '@angular/core'; -import { errorMessage } from '@placeos/common'; import { IconComponent } from '@placeos/components'; -import { StoreService } from '../data/store.service'; import { MapObject, ValidationIssue } from '../data/types'; import { EditorStateService } from './editor-state.service'; -import { PreflightCheck, preflightChecks } from './svg-export'; +import { + PreflightCheck, + preflightChecks, + validateObjects, +} from './svg-export'; @Component({ selector: 'map-studio-validation-panel', @@ -18,20 +20,13 @@ import { PreflightCheck, preflightChecks } from './svg-export';
- @if (error(); as message) { -
- {{ message }} -
- } - - @if (has_run() && !running()) { + @if (has_run()) {
@@ -59,11 +54,11 @@ import { PreflightCheck, preflightChecks } from './svg-export'; }
- @if (!has_run() && !running()) { + @if (!has_run()) {

Run the checks to find problems before you publish.

- } @else if (stale() && !running()) { + } @else if (stale()) {

@@ -74,8 +69,7 @@ import { PreflightCheck, preflightChecks } from './svg-export'; !stale() && has_run() && !issues().length && - !failed_checks().length && - !running() + !failed_checks().length ) {

+ @if (!configured()) { +
+

+ Public events are not configured yet +

+

+ Configure a default public events source in the + authority settings, or provide one with the shared + event link. +

+
+ } `, @@ -81,4 +100,6 @@ export class PublicPageShellComponent { public readonly description = input( 'Browse public events, view details, and register to attend.', ); + /** False when there is no events source to show */ + public readonly configured = input(true); } diff --git a/apps/public/src/tests/public-event.helpers.spec.ts b/apps/public/src/tests/public-event.helpers.spec.ts index 8ff015dc701..a1af88dcae2 100644 --- a/apps/public/src/tests/public-event.helpers.spec.ts +++ b/apps/public/src/tests/public-event.helpers.spec.ts @@ -1,6 +1,9 @@ +import { convertToParamMap } from '@angular/router'; import { eventDescriptionText, isSystemEvent, + preservedQueryParams, + querySystemId, } from '../app/public-event.helpers'; describe('public event helpers', () => { @@ -22,6 +25,26 @@ describe('public event helpers', () => { ); }); + it('should read the source id from the first query param that is set', () => { + expect(querySystemId(convertToParamMap({ id: 'c', system: 'b' }))).toBe( + 'b', + ); + expect(querySystemId(convertToParamMap({}))).toBe(''); + }); + + it('should keep only the query params that do not pick a source or event', () => { + expect( + preservedQueryParams({ + system_id: 'a', + system: 'b', + id: 'c', + event_id: 'e', + direct_event: 'true', + lang: 'en', + }), + ).toEqual({ lang: 'en' }); + }); + it('should read description text without creating elements in the page', () => { const create = vi.spyOn(document, 'createElement'); expect( diff --git a/apps/public/src/tests/public-events.component.spec.ts b/apps/public/src/tests/public-events.component.spec.ts index e7219ea99b8..990e7ae3072 100644 --- a/apps/public/src/tests/public-events.component.spec.ts +++ b/apps/public/src/tests/public-events.component.spec.ts @@ -150,6 +150,16 @@ describe('PublicEventsComponent', () => { expect(fixture.componentInstance.registered()).toBe(true); }); + it('stays unregistered when registration fails', async () => { + service.authenticated.set(true); + service.guest.set({ name: 'A', email: 'a@b.com' }); + service.default_system_id.set('sys-1'); + service.register.mockRejectedValueOnce(new Error('Full')); + const fixture = await init(); + await fixture.componentInstance.register({ id: 'e1' }); + expect(fixture.componentInstance.registered()).toBe(false); + }); + it('does not load events when there is no system id', async () => { service.authenticated.set(true); service.guest.set({ name: 'A', email: 'a@b.com' }); diff --git a/apps/public/src/tests/public-events.service.spec.ts b/apps/public/src/tests/public-events.service.spec.ts index f4b8edc1aab..83aaa365553 100644 --- a/apps/public/src/tests/public-events.service.spec.ts +++ b/apps/public/src/tests/public-events.service.spec.ts @@ -56,6 +56,19 @@ describe('PublicEventsService', () => { expect(service.loading()).toBe(''); }); + it('should show an authority load failure as the error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false } as Response), + ); + const service = TestBed.inject(PublicEventsService); + + await service.init(); + + expect(service.error()).toBe('Unable to load PlaceOS authority.'); + vi.unstubAllGlobals(); + }); + it('should ignore stored values that are not guest details', () => { localStorage.setItem( 'PLACEOS.public.guest', diff --git a/apps/public/src/tests/public-page-shell.component.spec.ts b/apps/public/src/tests/public-page-shell.component.spec.ts index a3d5b1f5c06..2e2ad1c4418 100644 --- a/apps/public/src/tests/public-page-shell.component.spec.ts +++ b/apps/public/src/tests/public-page-shell.component.spec.ts @@ -44,6 +44,19 @@ describe('PublicPageShellComponent', () => { expect(projected?.textContent).toContain('Projected body'); }); + it('shows the not-configured message only when not configured', () => { + const fixture = TestBed.createComponent(PublicPageShellComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).not.toContain( + 'not configured yet', + ); + fixture.componentRef.setInput('configured', false); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain( + 'Public events are not configured yet', + ); + }); + it('falls back to the default logo and description', () => { const fixture = TestBed.createComponent(PublicPageShellComponent); fixture.detectChanges(); diff --git a/apps/stagehand/src/app/alerts.component.ts b/apps/stagehand/src/app/alerts.component.ts index de6707d1dcb..e445911cda5 100644 --- a/apps/stagehand/src/app/alerts.component.ts +++ b/apps/stagehand/src/app/alerts.component.ts @@ -15,374 +15,324 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { - AsyncHandler, - OrganisationService, - settingSignal, -} from '@placeos/common'; +import { AsyncHandler } from '@placeos/common'; import { IconComponent, SimpleTableComponent } from '@placeos/components'; import { showSystem } from '@placeos/ts-client'; import { DashboardsService } from './dashboards/dashboards.service'; -import { SidebarComponent } from './ui/sidebar.component'; +import { backofficeLink } from './ui/sidebar.component'; +import { StagehandPageComponent } from './ui/stagehand-page.component'; @Component({ selector: 'stagehand-alerts', template: ` -
- - -
-
+
+ @if (dashboard()) { +
-

AV Systems Alerts

-
- @if (dashboard()) { -
settings + + } +
+ + + Disconnected Devices - settings - - } -
+ @for (item of dashboards(); track item.id) { + {{ + item.name + }} + } + + +
+
+
+ warning +
+

Critical

+
+ Attention required +
+
+
+ {{ critical_alerts() || '0' }} +
+
+
+ error +
+

Warnings

+
+ May require attention +
+
+
+ {{ warning_alerts() || '0' }} +
+
+
+ schedule +
+

Open

+
+ Pending resolution +
+
+
+ {{ open_alerts() || '0' }} +
+
+
+
+
+ + search + + +
+
+
- Disconnected DevicesAll Severities + Critical - @for (item of dashboards(); track item.id) { - {{ - item.name - }} - } + Warning + Info
-
-
-
-
+ - warning -
-

Critical

-
- Attention required -
-
-
- {{ critical_alerts() || '0' }} -
+ All Devices + Display + Audio + Video + Network + Control System + +
+
+
+
+
+ @if (show_alert_list_loading()) { +
+ +
+ } @else { + + } + +
+
+ {{ row.subject }}
-
- error -
-

Warnings

-
- May require attention -
-
-
- {{ warning_alerts() || '0' }} -
+
+ {{ row.body }}
-
+ + +
+ {{ severity_types[data]?.icon }} - schedule -
-

Open

-
- Pending resolution -
-
-
- {{ open_alerts() || '0' }} -
-
+
{{ severity_types[data]?.text }}
+ +
-
- - search - - -
-
{{ device_types[alert.type]?.icon }} -
- - - All Severities - Critical - Warning - Info - - -
-
- - - All Devices - Display - Audio - Video - Network - Control System - - -
+
+ {{ device_types[alert.type]?.text }}
-
- @if (show_alert_list_loading()) { -
- -
- } @else { - - } - -
-
- {{ row.subject }} -
-
- {{ row.body }} -
-
-
- -
- {{ severity_types[data]?.icon }} -
{{ severity_types[data]?.text }}
-
-
- -
- {{ alert.device }} - {{ device_types[alert.type]?.icon }} -
- {{ device_types[alert.type]?.text }} -
-
-
- -
- @let name = locationName(data); -
{{ name || data }}
- @if (name) { -
- {{ data }} -
- } -
-
- -
+ + +
+ @let name = locationName(data); +
{{ name || data }}
+ @if (name) { +
{{ data }}
- - - - + } +
+
+ +
+ {{ data }}
- +
+ + +
-
+ `, styles: [``], imports: [ @@ -393,7 +343,7 @@ import { SidebarComponent } from './ui/sidebar.component'; MatFormFieldModule, MatSelectModule, MatInputModule, - SidebarComponent, + StagehandPageComponent, FormsModule, MatTooltipModule, MatProgressSpinnerModule, @@ -404,7 +354,6 @@ export class AlertsComponent extends AsyncHandler implements OnInit { private _route = inject(ActivatedRoute); private _router = inject(Router); private _dashboards = inject(DashboardsService); - private _org = inject(OrganisationService); private _initialized = signal(false); private _waiting_for_first_alert = signal(true); private _location_names = signal>({}); @@ -446,8 +395,7 @@ export class AlertsComponent extends AsyncHandler implements OnInit { const severity = this.severity(); const device_type = this.device_type(); const status = this.status(); - const region = this._dashboards.region_id(); - const building = this._dashboards.building_id(); + const location_params = this._dashboards.location_query_params(); const initialized = this._initialized(); // Skip until after we've loaded from query params @@ -460,10 +408,7 @@ export class AlertsComponent extends AsyncHandler implements OnInit { severity: severity || undefined, device_type: device_type || undefined, status: status || undefined, - // Use 'all' to represent all regions selected, actual region id otherwise - region: region || 'all', - // Use 'all' to represent all buildings when region is selected - building: region ? building || 'all' : undefined, + ...location_params, }; this._router.navigate([], { @@ -500,10 +445,7 @@ export class AlertsComponent extends AsyncHandler implements OnInit { () => this._waiting_for_first_alert() && this.alert_list().length < 1, ); - public readonly backoffice_link = settingSignal( - 'backoffice_link', - `${location.origin}/backoffice/`, - ); + public readonly backoffice_link = backofficeLink(); public ngOnInit() { // Load filters from query parameters @@ -520,35 +462,7 @@ export class AlertsComponent extends AsyncHandler implements OnInit { if (query_params['status']) { this.status.set(query_params['status']); } - // Handle region query param - 'all' means all regions, otherwise specific region id - const region_param = query_params['region']; - const building_param = query_params['building']; - if (region_param === 'all') { - // Explicitly set to all regions via dashboards service - this._dashboards.setRegionFromParams('', ''); - } else if (region_param) { - const region = this._org.regions.find((r) => r.id === region_param); - if (region) { - this._org.region = region; - // Handle building param - 'all' means all buildings, otherwise specific building id - const building_id = - building_param === 'all' ? '' : building_param || ''; - // Set via dashboards service to prevent constructor overwrite - this._dashboards.setRegionFromParams(region.id, building_id); - - // Only apply building to org if specific building is set (not 'all') - if (building_param && building_param !== 'all') { - const building = this._org.buildings.find( - (b) => b.id === building_param, - ); - if (building) { - this._org.building = building; - } - } - } - } - // Note: If no region param exists, we don't call setRegionFromParams - // so the constructor can set defaults from org service + this._dashboards.applyLocationParams(query_params); // Enable effect after loading from query params this._initialized.set(true); diff --git a/apps/stagehand/src/app/analytics.component.ts b/apps/stagehand/src/app/analytics.component.ts index bc3e2540025..cc67935bab3 100644 --- a/apps/stagehand/src/app/analytics.component.ts +++ b/apps/stagehand/src/app/analytics.component.ts @@ -5,7 +5,7 @@ import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { IconComponent, SafePipe, TranslatePipe } from '@placeos/components'; -import { SidebarComponent } from './ui/sidebar.component'; +import { StagehandPageComponent } from './ui/stagehand-page.component'; interface AnalyticsPage { name: string; @@ -15,63 +15,44 @@ interface AnalyticsPage { @Component({ selector: 'stagehand-analytics', template: ` -
- -
-
-

AV Systems Analytics

-
- - - @for ( - page of analytics_pages(); - track page.name - ) { - {{ - page.name - }} - } - - -
-
-
- @if (page()?.url; as url) { - - - } @else { -
- arrow_warm_up -

- {{ - 'Select an analytics dashboard from above' - | translate - }} -

-
- } -
+ +
+ + + @for (page of analytics_pages(); track page.name) { + {{ + page.name + }} + } + +
-
+ @if (page()?.url; as url) { + + + } @else { +
+ arrow_warm_up +

+ {{ + 'Select an analytics dashboard from above' + | translate + }} +

+
+ } + `, styles: [``], imports: [ @@ -81,7 +62,7 @@ interface AnalyticsPage { MatFormFieldModule, MatSelectModule, FormsModule, - SidebarComponent, + StagehandPageComponent, ], }) export class AnalyticsComponent extends AsyncHandler { diff --git a/apps/stagehand/src/app/dashboards/alert-condition-compare-form.component.ts b/apps/stagehand/src/app/dashboards/alert-condition-compare-form.component.ts index 7602df55ca6..c645d1578bc 100644 --- a/apps/stagehand/src/app/dashboards/alert-condition-compare-form.component.ts +++ b/apps/stagehand/src/app/dashboards/alert-condition-compare-form.component.ts @@ -22,8 +22,8 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { Identity, i18n, notifyError } from '@placeos/common'; +import { TranslatePipe } from '@placeos/components'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; type ComparisonSide = 'left' | 'right'; diff --git a/apps/stagehand/src/app/dashboards/alert-conditions-modal.component.ts b/apps/stagehand/src/app/dashboards/alert-conditions-modal.component.ts index f0eed7725ad..1e392bb0c4d 100644 --- a/apps/stagehand/src/app/dashboards/alert-conditions-modal.component.ts +++ b/apps/stagehand/src/app/dashboards/alert-conditions-modal.component.ts @@ -9,8 +9,10 @@ import { TriggerConditionOperator, TriggerStatusVariable, } from '@placeos/ts-client'; -import { FullscreenModalShellComponent } from 'libs/components/src/lib/fullscreen-modal-shell.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; +import { + FullscreenModalShellComponent, + TranslatePipe, +} from '@placeos/components'; import { AlertConditionComparisonFormComponent } from './alert-condition-compare-form.component'; export interface AlertConditionData { diff --git a/apps/stagehand/src/app/dashboards/dashboard-alert-list.component.ts b/apps/stagehand/src/app/dashboards/dashboard-alert-list.component.ts index 749be7962e7..b83407d37fa 100644 --- a/apps/stagehand/src/app/dashboards/dashboard-alert-list.component.ts +++ b/apps/stagehand/src/app/dashboards/dashboard-alert-list.component.ts @@ -10,9 +10,11 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, RouterModule } from '@angular/router'; import { AsyncHandler } from '@placeos/common'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SimpleTableComponent } from 'libs/components/src/lib/simple-table.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; +import { + IconComponent, + SimpleTableComponent, + TranslatePipe, +} from '@placeos/components'; import { DashboardsService } from './dashboards.service'; @Component({ diff --git a/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts b/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts index 90aeb669196..85eb7a7d62a 100644 --- a/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts +++ b/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts @@ -27,10 +27,12 @@ import { TriggerConditions, updateAlert, } from '@placeos/ts-client'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SettingsToggleComponent } from 'libs/components/src/lib/settings-toggle.component'; -import { SimpleTableComponent } from 'libs/components/src/lib/simple-table.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; +import { + IconComponent, + SettingsToggleComponent, + SimpleTableComponent, + TranslatePipe, +} from '@placeos/components'; import { AlertConditionModalComponent } from './alert-conditions-modal.component'; import { DashboardsService } from './dashboards.service'; import { SystemSearchFieldComponent } from './system-search-field.component'; diff --git a/apps/stagehand/src/app/dashboards/dashboard-list.component.ts b/apps/stagehand/src/app/dashboards/dashboard-list.component.ts index c85ce873fa8..0979b08d086 100644 --- a/apps/stagehand/src/app/dashboards/dashboard-list.component.ts +++ b/apps/stagehand/src/app/dashboards/dashboard-list.component.ts @@ -11,9 +11,11 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; import { PlaceAlertDashboard } from '@placeos/ts-client'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SimpleTableComponent } from 'libs/components/src/lib/simple-table.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; +import { + IconComponent, + SimpleTableComponent, + TranslatePipe, +} from '@placeos/components'; import { DashboardsService } from './dashboards.service'; @Component({ diff --git a/apps/stagehand/src/app/dashboards/dashboard-mange.component.ts b/apps/stagehand/src/app/dashboards/dashboard-mange.component.ts index c8d50f7f079..df21b8cc4fb 100644 --- a/apps/stagehand/src/app/dashboards/dashboard-mange.component.ts +++ b/apps/stagehand/src/app/dashboards/dashboard-mange.component.ts @@ -16,9 +16,11 @@ import { notifyError, } from '@placeos/common'; import { addAlertDashboard, updateAlertDashboard } from '@placeos/ts-client'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SettingsToggleComponent } from 'libs/components/src/lib/settings-toggle.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; +import { + IconComponent, + SettingsToggleComponent, + TranslatePipe, +} from '@placeos/components'; import { DashboardsService } from './dashboards.service'; @Component({ diff --git a/apps/stagehand/src/app/dashboards/dashboards.component.ts b/apps/stagehand/src/app/dashboards/dashboards.component.ts index 94189e61665..5721b75bd26 100644 --- a/apps/stagehand/src/app/dashboards/dashboards.component.ts +++ b/apps/stagehand/src/app/dashboards/dashboards.component.ts @@ -10,69 +10,63 @@ import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { Router, RouterModule } from '@angular/router'; import { AsyncHandler } from '@placeos/common'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { SidebarComponent } from '../ui/sidebar.component'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { StagehandPageComponent } from '../ui/stagehand-page.component'; import { DashboardsService } from './dashboards.service'; @Component({ selector: `stagehand-dashboards`, template: ` -
- -
-
+ @if (page() === 'alerts') { + - @if (page() === 'alerts') { - - arrow_back - - } -

- {{ - (page() === 'alerts' - ? 'APP.STAGEHAND.DASHBOARD_ALERTS_HEADER' - : 'APP.STAGEHAND.DASHBOARD_HEADER' - ) | translate - }} -

- @if (page() === 'alerts') { -
- {{ dashboard()?.name }} -
- } -
- {{ - (page() === 'alerts' - ? 'APP.STAGEHAND.DASHBOARD_ALERTS_ADD' - : 'APP.STAGEHAND.DASHBOARD_ADD' - ) | translate - }} -
-
arrow_back + + } + + @if (page() === 'alerts') { +
- -
-
-
+ {{ dashboard()?.name }} +
+ } +
+ {{ + (page() === 'alerts' + ? 'APP.STAGEHAND.DASHBOARD_ALERTS_ADD' + : 'APP.STAGEHAND.DASHBOARD_ADD' + ) | translate + }} + + `, styles: [], imports: [ - SidebarComponent, + StagehandPageComponent, MatRippleModule, RouterModule, TranslatePipe, diff --git a/apps/stagehand/src/app/dashboards/dashboards.service.ts b/apps/stagehand/src/app/dashboards/dashboards.service.ts index 35777758dc1..fe96752f096 100644 --- a/apps/stagehand/src/app/dashboards/dashboards.service.ts +++ b/apps/stagehand/src/app/dashboards/dashboards.service.ts @@ -1,4 +1,11 @@ -import { effect, inject, Injectable, signal, untracked } from '@angular/core'; +import { + computed, + effect, + inject, + Injectable, + signal, + untracked, +} from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { AsyncHandler, @@ -83,6 +90,19 @@ export class DashboardsService extends AsyncHandler { ); public readonly dashboard_alerts = signal([]); + /** + * Region and building query params that keep the URL in step with the + * selection. 'all' stands for every region, or every building of the + * selected region. + */ + public readonly location_query_params = computed(() => { + const region = this.region_id(); + return { + region: region || 'all', + building: region ? this.building_id() || 'all' : undefined, + }; + }); + constructor() { super(); @@ -148,22 +168,9 @@ export class DashboardsService extends AsyncHandler { /** Initialize region/building from URL query params */ private _initFromQueryParams() { // Use query string captured at construction time to avoid reading - // params injected by the _query_sync effect in AlertsComponent + // params the pages write back to the URL const params = new URLSearchParams(this._initial_query_string); - - const region_param = params.get('region'); - const building_param = params.get('building'); - - if (region_param) { - this._region_set_from_params = true; - // 'all' means empty string (all regions/buildings) - this.region_id.set(region_param === 'all' ? '' : region_param); - if (building_param) { - this.building_id.set( - building_param === 'all' ? '' : building_param, - ); - } - } + this.applyLocationParams(Object.fromEntries(params)); } /** Set region from query params, preventing constructor from overwriting */ @@ -175,6 +182,28 @@ export class DashboardsService extends AsyncHandler { } } + /** + * Applies the `region` and `building` URL query params. 'all' selects + * every region, or every building of the region. A region id applies only + * when it is a known region; it then also becomes the org region, and a + * known building id the org building. With no region param nothing + * changes, so the org selection stays the default. + */ + public applyLocationParams(params: { region?: string; building?: string }) { + const { region: region_param, building: building_param } = params; + if (!region_param) return; + if (region_param === 'all') return this.setRegionFromParams('', ''); + const region = this._org.regions.find((r) => r.id === region_param); + if (!region) return; + this._org.region = region; + const building_id = + building_param === 'all' ? '' : building_param || ''; + this.setRegionFromParams(region.id, building_id); + if (!building_id) return; + const building = this._org.buildings.find((b) => b.id === building_id); + if (building) this._org.building = building; + } + /** * Shows `key` in `loading` while `task` runs. A failed task is logged * and reported, and the key is always removed. diff --git a/apps/stagehand/src/app/dashboards/system-search-field.component.ts b/apps/stagehand/src/app/dashboards/system-search-field.component.ts index a8d62aa87fe..4c381f24fb4 100644 --- a/apps/stagehand/src/app/dashboards/system-search-field.component.ts +++ b/apps/stagehand/src/app/dashboards/system-search-field.component.ts @@ -17,9 +17,9 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { AsyncHandler, provideValueAccessor } from '@placeos/common'; +import { IconComponent } from '@placeos/components'; import { inGroupScope } from '../support-groups'; import { SupportGroupsService } from '../support-groups.service'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; @Component({ selector: 'system-search-field', diff --git a/apps/stagehand/src/app/recorder-view.component.ts b/apps/stagehand/src/app/recorder-view.component.ts index 2a4763dd257..b64f1b99313 100644 --- a/apps/stagehand/src/app/recorder-view.component.ts +++ b/apps/stagehand/src/app/recorder-view.component.ts @@ -1,13 +1,8 @@ -import { - Component, - inject, - OnInit, - signal, -} from '@angular/core'; +import { Component, inject, OnInit, signal } from '@angular/core'; import { AsyncHandler, OrganisationService } from '@placeos/common'; import { SafePipe } from '@placeos/components'; import { showMetadata } from '@placeos/ts-client'; -import { SidebarComponent } from './ui/sidebar.component'; +import { StagehandPageComponent } from './ui/stagehand-page.component'; interface RecorderStreamImage { id: string; @@ -23,17 +18,10 @@ interface RecorderStreamMetadata { @Component({ selector: `recorder-grid-view`, - template: `
- -
-
-

Recorder Streams

-
-
+
@for (image of images(); track image.id) {
} -
-
-
`, +
+ + `, styles: ``, - imports: [SidebarComponent, SafePipe], + imports: [StagehandPageComponent, SafePipe], }) export class RecorderGridViewComponent extends AsyncHandler implements OnInit { private _org = inject(OrganisationService); diff --git a/apps/stagehand/src/app/remote-support.component.ts b/apps/stagehand/src/app/remote-support.component.ts index ef80346ef0c..dc8036bb73f 100644 --- a/apps/stagehand/src/app/remote-support.component.ts +++ b/apps/stagehand/src/app/remote-support.component.ts @@ -6,6 +6,7 @@ import { inject, OnInit, signal, + WritableSignal, } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; @@ -18,7 +19,6 @@ import { ActivatedRoute, Router } from '@angular/router'; import { AsyncHandler, CalendarEvent, - OrganisationService, randomInt, settingSignal, Space, @@ -35,365 +35,320 @@ import { CameraSnapshotModalComponent } from './camera-snapshot-modal.component' import { Alert } from './dashboards/broker-alerts'; import { DashboardsService } from './dashboards/dashboards.service'; import { SupportService } from './support.service'; -import { SidebarComponent } from './ui/sidebar.component'; +import { backofficeLink } from './ui/sidebar.component'; +import { StagehandPageComponent } from './ui/stagehand-page.component'; @Component({ selector: 'stagehand-remote-support', template: ` -
- -
-
+
+
-

- AV Systems Remote Support -

-
-
sensors +
+

Total Rooms

+
+ Total room count with devices +
+
+
+ {{ zone_rooms()?.length || '0' }} +
+
+
-
warning +
+

Active Alerts

+
+
+ {{ room_alerts().length }} +
+
+
+
+
+ -
search - sensors -
-

Total Rooms

-
- Total room count with devices -
-
-
- {{ zone_rooms()?.length || '0' }} -
-
- +
+ + - warning -
-

- Active Alerts -

-
-
- {{ room_alerts().length }} -
-
-
-
-
- - search - - -
+ All Rooms + In Use + Available + Has Issues + + +
+
+
+ @for (space of room_list(); track space.id) { + + } + + + + +
+
- - - All Rooms - In Use - Available - Has Issues - - + {{ + status()[space.id] === 'free' + ? 'Available' + : status()[space.id] === 'busy' + ? 'In Use' + : status()[space.id] === 'pending' + ? 'Pending' + : 'No status' + }}
-
- @for (space of room_list(); track space.id) { - + + + @let events = key === 'current_event' ? current() : next(); +
+ @if (events[space.id]) { +
{{ events[space.id].title }}
+
+ {{ + events[space.id].event_start * 1000 + | date: 'shortTime' + }} + – + {{ + events[space.id].event_end * 1000 + | date: 'shortTime' + }} +
+ } @else { + None } - + @let feed_url = cameraSnapshotFeed(space); + @if (feed_url) { + - } @else { -
- hide_image -
- } - - - @if (data?.length) { - @let issue = data[0]; -
- {{ - issue.severity === 'critical' - ? 'warning' - : 'error' - }} -
-
{{ issue.subject }}
- @if (data.length > 1) { -
- +{{ data.length - 1 }} more - issues -
- } -
-
- } @else { -
- No issues -
- } -
- - - +
+ } @else { +
No issues
+ } +
+ + - +
-
+ `, styles: [ ` @@ -419,7 +374,7 @@ import { SidebarComponent } from './ui/sidebar.component'; MatFormFieldModule, MatSelectModule, MatInputModule, - SidebarComponent, + StagehandPageComponent, FormsModule, MatTooltipModule, ViewportVisibilityComponent, @@ -429,7 +384,6 @@ import { SidebarComponent } from './ui/sidebar.component'; export class RemoteSupportComponent extends AsyncHandler implements OnInit { private readonly _dashboard = inject(DashboardsService); private readonly _support = inject(SupportService); - private readonly _org = inject(OrganisationService); private readonly _router = inject(Router); private readonly _route = inject(ActivatedRoute); private readonly _dialog = inject(MatDialog); @@ -450,8 +404,7 @@ export class RemoteSupportComponent extends AsyncHandler implements OnInit { private _query_checks = effect(() => { const search = this.search_term(); const state = this.state(); - const region = this._dashboard.region_id(); - const building = this._dashboard.building_id(); + const location_params = this._dashboard.location_query_params(); const initialized = this._initialized(); // Skip until after we've loaded from query params @@ -462,10 +415,7 @@ export class RemoteSupportComponent extends AsyncHandler implements OnInit { const query_params: Record = { search: search || undefined, state: state || undefined, - // Use 'all' to represent all regions selected, actual region id otherwise - region: region || 'all', - // Use 'all' to represent all buildings when region is selected - building: region ? building || 'all' : undefined, + ...location_params, }; this._router.navigate([], { @@ -506,10 +456,7 @@ export class RemoteSupportComponent extends AsyncHandler implements OnInit { ); return alerts; }); - public readonly backoffice_link = settingSignal( - 'backoffice_link', - `${location.origin}/backoffice/`, - ); + public readonly backoffice_link = backofficeLink(); public readonly service_link = settingSignal( 'service_link', `${location.origin}/control/`, @@ -606,35 +553,7 @@ export class RemoteSupportComponent extends AsyncHandler implements OnInit { if (query_params['state']) { this.state.set(query_params['state']); } - // Handle region query param - 'all' means all regions, otherwise specific region id - const region_param = query_params['region']; - const building_param = query_params['building']; - if (region_param === 'all') { - // Explicitly set to all regions via dashboard service - this._dashboard.setRegionFromParams('', ''); - } else if (region_param) { - const region = this._org.regions.find((r) => r.id === region_param); - if (region) { - this._org.region = region; - // Handle building param - 'all' means all buildings, otherwise specific building id - const building_id = - building_param === 'all' ? '' : building_param || ''; - // Set via dashboard service to prevent constructor overwrite - this._dashboard.setRegionFromParams(region.id, building_id); - - // Only apply building to org if specific building is set (not 'all') - if (building_param && building_param !== 'all') { - const building = this._org.buildings.find( - (b) => b.id === building_param, - ); - if (building) { - this._org.building = building; - } - } - } - } - // Note: If no region param exists, we don't call setRegionFromParams - // so the constructor can set defaults from org service + this._dashboard.applyLocationParams(query_params); // Enable effect after loading from query params this._initialized.set(true); @@ -648,30 +567,29 @@ export class RemoteSupportComponent extends AsyncHandler implements OnInit { }); public setCurrentBooking(space: Space, event: CalendarEvent) { - const current = this.current(); - if (!event && current[space.id]) { - // a new object, so the signal sees the booking end - const { [space.id]: _, ...rest } = current; - this.current.set(rest); - return; - } - // Only update if the event actually changed - if (JSON.stringify(current[space.id]) !== JSON.stringify(event)) { - this.current.update((old) => ({ ...old, [space.id]: event })); - } + this._setBooking(this.current, space, event); } public setNextBooking(space: Space, event: CalendarEvent) { - const current = this.next(); + this._setBooking(this.next, space, event); + } + + /** Sets the room's booking in `bookings`, or removes it when there is none */ + private _setBooking( + bookings: WritableSignal>, + space: Space, + event: CalendarEvent, + ) { + const current = bookings(); if (!event && current[space.id]) { // a new object, so the signal sees the booking end const { [space.id]: _, ...rest } = current; - this.next.set(rest); + bookings.set(rest); return; } // Only update if the event actually changed if (JSON.stringify(current[space.id]) !== JSON.stringify(event)) { - this.next.update((old) => ({ ...old, [space.id]: event })); + bookings.update((old) => ({ ...old, [space.id]: event })); } } diff --git a/apps/stagehand/src/app/ui/sidebar.component.ts b/apps/stagehand/src/app/ui/sidebar.component.ts index 52dfeb34845..9eab8b05631 100644 --- a/apps/stagehand/src/app/ui/sidebar.component.ts +++ b/apps/stagehand/src/app/ui/sidebar.component.ts @@ -1,9 +1,4 @@ -import { - Component, - computed, - inject, - signal, -} from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatDialog } from '@angular/material/dialog'; @@ -11,17 +6,25 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; -import { firstValueFrom } from 'rxjs'; import { OrganisationService, SettingsService, settingSignal, } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; +import { firstValueFrom } from 'rxjs'; import { DashboardsService } from '../dashboards/dashboards.service'; -import { SupportGroupsService } from '../support-groups.service'; import { PushNotificationSettingsComponent } from '../push-notification-settings.component'; import { AlertNotificationService } from '../push-notification.service'; +import { SupportGroupsService } from '../support-groups.service'; + +/** + * Signal of the backoffice URL from the `backoffice_link` setting. Call it + * after the settings load, for example in a component field. + */ +export function backofficeLink() { + return settingSignal('backoffice_link', `${location.origin}/backoffice/`); +} /** Start collapsed on narrow screens, so the sidebar does not cover the page */ const COMPACT_SIGNAL = signal( @@ -135,10 +138,8 @@ const COMPACT_SIGNAL = signal( }} @for (region of r_list; track region.id) { {{ - region.display_name || - region.name - }} + region.display_name || region.name + }} } @@ -167,9 +168,8 @@ const COMPACT_SIGNAL = signal( }} @for (bld of bld_list; track bld.id) { {{ - bld.display_name || bld.name - }} + bld.display_name || bld.name + }} } @@ -178,82 +178,20 @@ const COMPACT_SIGNAL = signal( } }
- - notifications - @if (!is_compact()) { - Alerts - } - - - wifi - @if (!is_compact()) { - Remote Support - } - - @if (analytics_pages()?.length > 0) { - - show_chart - @if (!is_compact()) { - Analytics - } - - } - - dashboard - @if (!is_compact()) { - Manage Dashboards - } - - @if (show_recorder_view()) { + @for (link of nav_links(); track link.route) { - stream + {{ link.icon }} @if (!is_compact()) { - Recorder Streams + {{ link.name }} } } @@ -404,15 +342,53 @@ export class SidebarComponent { public readonly can_change_dark_mode = computed( () => !!this._allow_dark_mode(), ); - public readonly backoffice_link = settingSignal( - 'backoffice_link', - `${location.origin}/backoffice/`, - ); + public readonly backoffice_link = backofficeLink(); public readonly show_recorder_view = settingSignal( 'show_recorder_view', false, ); public readonly analytics_pages = settingSignal('analytics_pages', []); + /** Page links, without the optional pages that are turned off */ + public readonly nav_links = computed(() => [ + { + route: '/alerts', + name: 'Alerts', + aria_label: 'Alerts', + icon: 'notifications', + }, + { + route: '/remote-support', + name: 'Remote Support', + aria_label: 'Remote support', + icon: 'wifi', + }, + ...(this.analytics_pages()?.length > 0 + ? [ + { + route: '/analytics', + name: 'Analytics', + aria_label: 'Analytics', + icon: 'show_chart', + }, + ] + : []), + { + route: '/dashboards', + name: 'Manage Dashboards', + aria_label: 'Manage dashboards', + icon: 'dashboard', + }, + ...(this.show_recorder_view() + ? [ + { + route: '/recorder-grid', + name: 'Recorder Streams', + aria_label: 'Recorder streams', + icon: 'stream', + }, + ] + : []), + ]); public readonly region = this._dash.region_id; public readonly building = this._dash.building_id; diff --git a/apps/stagehand/src/app/ui/stagehand-page.component.ts b/apps/stagehand/src/app/ui/stagehand-page.component.ts new file mode 100644 index 00000000000..40e2a6305b2 --- /dev/null +++ b/apps/stagehand/src/app/ui/stagehand-page.component.ts @@ -0,0 +1,38 @@ +import { Component, input } from '@angular/core'; +import { SidebarComponent } from './sidebar.component'; + +/** + * Page shell of the Stagehand pages: the sidebar, a header with the page + * heading, and the main area that the skip link goes to. + * Header content with the `page-header-start` attribute shows before the + * heading, and content with the `page-header` attribute after it. Other content + * goes in the main area. + */ +@Component({ + selector: 'stagehand-page', + template: ` +
+ +
+
+ +

{{ heading() }}

+ +
+
+ +
+
+
+ `, + imports: [SidebarComponent], +}) +export class StagehandPageComponent { + /** Page heading */ + public readonly heading = input.required(); +} diff --git a/apps/stagehand/src/tests/alert-condition-compare-form.component.spec.ts b/apps/stagehand/src/tests/alert-condition-compare-form.component.spec.ts index 86a3517ebe9..913a9d987ac 100644 --- a/apps/stagehand/src/tests/alert-condition-compare-form.component.spec.ts +++ b/apps/stagehand/src/tests/alert-condition-compare-form.component.spec.ts @@ -3,12 +3,12 @@ import { ComponentFixtureAutoDetect } from '@angular/core/testing'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { TriggerConditionOperator } from '@placeos/ts-client'; -import { setNotifyOutlet } from 'libs/common/src/lib/notifications'; vi.mock('@placeos/ts-client', { spy: true }); import { queryModules, systemModuleState } from '@placeos/ts-client'; +import { setNotifyOutlet } from '@placeos/common'; import { AlertConditionComparisonFormComponent, calculateModuleIndex, diff --git a/apps/stagehand/src/tests/alerts.component.spec.ts b/apps/stagehand/src/tests/alerts.component.spec.ts index 1b8bc51ac50..35402343010 100644 --- a/apps/stagehand/src/tests/alerts.component.spec.ts +++ b/apps/stagehand/src/tests/alerts.component.spec.ts @@ -1,16 +1,15 @@ -import type { Mock } from 'vitest'; import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { ActivatedRoute, Router } from '@angular/router'; -import { OrganisationService } from '@placeos/common'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { IconComponent, SimpleTableComponent } from '@placeos/components'; import { showSystem } from '@placeos/ts-client'; import { MockComponent, MockProvider } from 'ng-mocks'; +import type { Mock } from 'vitest'; import { AlertsComponent } from '../app/alerts.component'; import { DashboardsService } from '../app/dashboards/dashboards.service'; -import { SidebarComponent } from '../app/ui/sidebar.component'; +import { StagehandPageComponent } from '../app/ui/stagehand-page.component'; vi.mock('@placeos/ts-client', { spy: true }); @@ -36,13 +35,12 @@ describe('AlertsComponent', () => { let router: { navigate: Mock }; let route: unknown; let dashboards_service: any; - let org: unknown; const create_component = createComponentFactory({ component: AlertsComponent, detectChanges: false, declarations: [ - MockComponent(SidebarComponent), + MockComponent(StagehandPageComponent), MockComponent(SimpleTableComponent), MockComponent(IconComponent), ], @@ -69,15 +67,14 @@ describe('AlertsComponent', () => { loadDashboards: vi.fn(), setDashboard: vi.fn(() => Promise.resolve()), listenForDashboardAlerts: vi.fn(), - setRegionFromParams: vi.fn(), + applyLocationParams: vi.fn(), + location_query_params: signal({ region: 'all' }), }; - org = { regions: [], buildings: [], region: null, building: null }; return create_component({ providers: [ { provide: Router, useValue: router }, { provide: ActivatedRoute, useValue: route }, MockProvider(DashboardsService, dashboards_service), - MockProvider(OrganisationService, org), ], }); }; @@ -103,9 +100,24 @@ describe('AlertsComponent', () => { it('should filter the alert list by severity, device type and status', () => { spectator = build(); dashboard_alerts.set([ - make_alert({ id: '1', severity: 'critical', type: 'display', status: 'open' }), - make_alert({ id: '2', severity: 'high', type: 'audio', status: 'closed' }), - make_alert({ id: '3', severity: 'critical', type: 'audio', status: 'open' }), + make_alert({ + id: '1', + severity: 'critical', + type: 'display', + status: 'open', + }), + make_alert({ + id: '2', + severity: 'high', + type: 'audio', + status: 'closed', + }), + make_alert({ + id: '3', + severity: 'critical', + type: 'audio', + status: 'open', + }), ]); spectator.component.severity.set('critical'); @@ -168,29 +180,13 @@ describe('AlertsComponent', () => { expect(dashboards_service.loadDashboards).toHaveBeenCalled(); }); - it('should apply all-regions scope when the region param is "all"', () => { - spectator = build({ region: 'all' }); - spectator.component.ngOnInit(); - expect(dashboards_service.setRegionFromParams).toHaveBeenCalledWith( - '', - '', - ); - }); - - it('should apply a specific region and building from query params', () => { - const region = { id: 'region-1' }; - const building = { id: 'building-1' }; - spectator = build({ region: 'region-1', building: 'building-1' }); - const injected_org: any = spectator.inject(OrganisationService); - injected_org.regions = [region]; - injected_org.buildings = [building]; + it('should apply the region and building query params on init', () => { + const params = { region: 'region-1', building: 'building-1' }; + spectator = build(params); spectator.component.ngOnInit(); - expect(injected_org.region).toBe(region); - expect(dashboards_service.setRegionFromParams).toHaveBeenCalledWith( - 'region-1', - 'building-1', + expect(dashboards_service.applyLocationParams).toHaveBeenCalledWith( + params, ); - expect(injected_org.building).toBe(building); }); it('should navigate to the selected dashboard route, keeping the filters', () => { diff --git a/apps/stagehand/src/tests/dashboards.service.spec.ts b/apps/stagehand/src/tests/dashboards.service.spec.ts index b5cb2da50c3..3deb1e81683 100644 --- a/apps/stagehand/src/tests/dashboards.service.spec.ts +++ b/apps/stagehand/src/tests/dashboards.service.spec.ts @@ -5,12 +5,11 @@ import { createServiceFactory, SpectatorService, } from '@ngneat/spectator/vitest'; -import { OrganisationService } from '@placeos/common'; +import { OrganisationService, setNotifyOutlet } from '@placeos/common'; import { MockProvider } from 'ng-mocks'; import { NEVER, of } from 'rxjs'; import * as ts_client from '@placeos/ts-client'; -import { setNotifyOutlet } from 'libs/common/src/lib/notifications'; import { DashboardsService } from '../app/dashboards/dashboards.service'; import { AlertNotificationService } from '../app/push-notification.service'; @@ -164,6 +163,60 @@ describe('DashboardsService', () => { }); }); + describe('applyLocationParams', () => { + it('should select every region for "all"', () => { + spectator.service.region_id.set('region-1'); + spectator.service.building_id.set('building-1'); + spectator.service.applyLocationParams({ region: 'all' }); + expect(spectator.service.region_id()).toBe(''); + expect(spectator.service.building_id()).toBe(''); + }); + + it('should apply a known region and building to the org', () => { + const region = { id: 'region-1' }; + const building = { id: 'building-1' }; + const injected: any = spectator.inject(OrganisationService); + injected.regions = [region]; + injected.buildings = [building]; + spectator.service.applyLocationParams({ + region: 'region-1', + building: 'building-1', + }); + expect(injected.region).toBe(region); + expect(injected.building).toBe(building); + expect(spectator.service.region_id()).toBe('region-1'); + expect(spectator.service.building_id()).toBe('building-1'); + }); + + it('should ignore an unknown region', () => { + const injected: any = spectator.inject(OrganisationService); + injected.regions = []; + spectator.service.applyLocationParams({ region: 'region-x' }); + expect(spectator.service.region_id()).toBe(''); + }); + }); + + describe('location_query_params', () => { + it('should write "all" for no region and keep building unset', () => { + expect(spectator.service.location_query_params()).toEqual({ + region: 'all', + building: undefined, + }); + }); + + it('should write "all" buildings for a region with no building', () => { + spectator.service.region_id.set('region-1'); + expect(spectator.service.location_query_params()).toEqual({ + region: 'region-1', + building: 'all', + }); + spectator.service.building_id.set('building-1'); + expect(spectator.service.location_query_params().building).toBe( + 'building-1', + ); + }); + }); + describe('setAlert', () => { it('should load the alert and clear the loading flag', async () => { const alert = { id: 'alert-1', name: 'Alert One' }; diff --git a/apps/stagehand/src/tests/dashboards/dashboard-alert-list.component.spec.ts b/apps/stagehand/src/tests/dashboards/dashboard-alert-list.component.spec.ts index c752221d599..9b76acf7aee 100644 --- a/apps/stagehand/src/tests/dashboards/dashboard-alert-list.component.spec.ts +++ b/apps/stagehand/src/tests/dashboards/dashboard-alert-list.component.spec.ts @@ -5,10 +5,9 @@ import { } from '@ngneat/spectator/vitest'; import { MockComponent, MockProvider } from 'ng-mocks'; +import { IconComponent, SimpleTableComponent } from '@placeos/components'; import { DashboardAlertListComponent } from '../../app/dashboards/dashboard-alert-list.component'; import { DashboardsService } from '../../app/dashboards/dashboards.service'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SimpleTableComponent } from 'libs/components/src/lib/simple-table.component'; describe('DashboardAlertListComponent', () => { let spectator: SpectatorRouting; diff --git a/apps/stagehand/src/tests/dashboards/dashboard-alert-manage.component.spec.ts b/apps/stagehand/src/tests/dashboards/dashboard-alert-manage.component.spec.ts index dd69b883a11..93632a4a889 100644 --- a/apps/stagehand/src/tests/dashboards/dashboard-alert-manage.component.spec.ts +++ b/apps/stagehand/src/tests/dashboards/dashboard-alert-manage.component.spec.ts @@ -9,14 +9,16 @@ import { import { MockComponent, MockProvider } from 'ng-mocks'; import { delay, of } from 'rxjs'; -import { setNotifyOutlet } from 'libs/common/src/lib/notifications'; import { addAlert, updateAlert } from '@placeos/ts-client'; +import { + IconComponent, + SettingsToggleComponent, + SimpleTableComponent, +} from '@placeos/components'; +import { setNotifyOutlet } from '@placeos/common'; import { DashboardAlertManageComponent } from '../../app/dashboards/dashboard-alert-manage.component'; import { DashboardsService } from '../../app/dashboards/dashboards.service'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SettingsToggleComponent } from 'libs/components/src/lib/settings-toggle.component'; -import { SimpleTableComponent } from 'libs/components/src/lib/simple-table.component'; import { SystemSearchFieldComponent } from '../../app/dashboards/system-search-field.component'; vi.mock('@placeos/ts-client', { spy: true }); diff --git a/apps/stagehand/src/tests/dashboards/dashboard-list.component.spec.ts b/apps/stagehand/src/tests/dashboards/dashboard-list.component.spec.ts index eb1696d9d2b..139246b5e32 100644 --- a/apps/stagehand/src/tests/dashboards/dashboard-list.component.spec.ts +++ b/apps/stagehand/src/tests/dashboards/dashboard-list.component.spec.ts @@ -5,10 +5,9 @@ import { } from '@ngneat/spectator/vitest'; import { MockComponent, MockProvider } from 'ng-mocks'; +import { IconComponent, SimpleTableComponent } from '@placeos/components'; import { DashboardListComponent } from '../../app/dashboards/dashboard-list.component'; import { DashboardsService } from '../../app/dashboards/dashboards.service'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SimpleTableComponent } from 'libs/components/src/lib/simple-table.component'; describe('DashboardListComponent', () => { let spectator: SpectatorRouting; diff --git a/apps/stagehand/src/tests/dashboards/dashboard-mange.component.spec.ts b/apps/stagehand/src/tests/dashboards/dashboard-mange.component.spec.ts index 51dca841ff4..df1cbe0b1db 100644 --- a/apps/stagehand/src/tests/dashboards/dashboard-mange.component.spec.ts +++ b/apps/stagehand/src/tests/dashboards/dashboard-mange.component.spec.ts @@ -6,13 +6,12 @@ import { } from '@ngneat/spectator/vitest'; import { MockComponent, MockProvider } from 'ng-mocks'; -import { setNotifyOutlet } from 'libs/common/src/lib/notifications'; import { addAlertDashboard, updateAlertDashboard } from '@placeos/ts-client'; +import { IconComponent, SettingsToggleComponent } from '@placeos/components'; +import { setNotifyOutlet } from '@placeos/common'; import { DashboardManageComponent } from '../../app/dashboards/dashboard-mange.component'; import { DashboardsService } from '../../app/dashboards/dashboards.service'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SettingsToggleComponent } from 'libs/components/src/lib/settings-toggle.component'; vi.mock('@placeos/ts-client', { spy: true }); diff --git a/apps/stagehand/src/tests/dashboards/dashboards.component.spec.ts b/apps/stagehand/src/tests/dashboards/dashboards.component.spec.ts index b8631c38293..90d9fe40abe 100644 --- a/apps/stagehand/src/tests/dashboards/dashboards.component.spec.ts +++ b/apps/stagehand/src/tests/dashboards/dashboards.component.spec.ts @@ -3,9 +3,9 @@ import { provideRouter } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MockComponent, MockProvider } from 'ng-mocks'; +import { IconComponent } from '@placeos/components'; import { DashboardsComponent } from '../../app/dashboards/dashboards.component'; import { DashboardsService } from '../../app/dashboards/dashboards.service'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; import { SidebarComponent } from '../../app/ui/sidebar.component'; describe('DashboardsComponent', () => { diff --git a/apps/stagehand/src/tests/remote-support.component.spec.ts b/apps/stagehand/src/tests/remote-support.component.spec.ts index 927604398a9..1ad9a7dcc73 100644 --- a/apps/stagehand/src/tests/remote-support.component.spec.ts +++ b/apps/stagehand/src/tests/remote-support.component.spec.ts @@ -1,11 +1,10 @@ -import type { Mock } from 'vitest'; import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { OrganisationService } from '@placeos/common'; import { MockProvider } from 'ng-mocks'; +import type { Mock } from 'vitest'; import { CameraSnapshotModalComponent } from '../app/camera-snapshot-modal.component'; import { DashboardsService } from '../app/dashboards/dashboards.service'; @@ -37,14 +36,12 @@ describe('RemoteSupportComponent', () => { detectChanges: false, // Render nothing: these tests exercise the component's signals, computeds // and methods, not its (heavy) template with the sidebar + data table. - overrideComponents: [[RemoteSupportComponent, { set: { template: '' } }]], + overrideComponents: [ + [RemoteSupportComponent, { set: { template: '' } }], + ], providers: [ MockProvider(DashboardsService, {}), MockProvider(SupportService, {}), - MockProvider(OrganisationService, { - regions: [], - buildings: [], - }), MockProvider(MatDialog, {}), MockProvider(Router, {}), MockProvider(ActivatedRoute, { @@ -70,7 +67,8 @@ describe('RemoteSupportComponent', () => { dashboard_alerts, setDashboard: vi.fn(), listenForDashboardAlerts: vi.fn(), - setRegionFromParams: vi.fn(), + applyLocationParams: vi.fn(), + location_query_params: signal({ region: 'all' }), }, }, { provide: SupportService, useValue: { space_list } }, @@ -84,7 +82,9 @@ describe('RemoteSupportComponent', () => { expect(spectator.component.snapshotUrl('')).toBe(''); const url = spectator.component.snapshotUrl('http://host/snap'); expect(url).toMatch(/^http:\/\/host\/snap\?t=\d+$/); - const with_query = spectator.component.snapshotUrl('http://host/snap?a=1'); + const with_query = spectator.component.snapshotUrl( + 'http://host/snap?a=1', + ); expect(with_query).toMatch(/^http:\/\/host\/snap\?a=1&t=\d+$/); }); @@ -147,7 +147,9 @@ describe('RemoteSupportComponent', () => { room({ id: 'b', zones: ['bld-2'] }), ] as any); building_id.set('bld-1'); - expect(spectator.component.zone_rooms().map((r) => r.id)).toEqual(['a']); + expect(spectator.component.zone_rooms().map((r) => r.id)).toEqual([ + 'a', + ]); }); it('should attach matching alerts to each room and de-duplicate them', () => { From 4b945acfdfb02a54b22eeb67123a7913115d9201 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Tue, 3 Nov 2026 08:01:34 +1100 Subject: [PATCH 21/21] fix(components): keep chat messages that cannot be sent If no chat system is set, the chat cannot connect. sendMessage now shows an error and returns false, and the chat keeps the text in the input. An error also shows if the connection closes before it opens. --- .../components/src/lib/chat/chat.component.ts | 3 +- libs/components/src/lib/chat/chat.service.ts | 37 ++++++++--- .../src/tests/chat.component.spec.ts | 12 +++- .../components/src/tests/chat.service.spec.ts | 65 ++++++++++++++++++- shared/assets/locale/ar.json | 1 + shared/assets/locale/en-AU.json | 1 + shared/assets/locale/en-GB.json | 1 + shared/assets/locale/en-US.json | 1 + shared/assets/locale/es.json | 1 + shared/assets/locale/fr-CA.json | 1 + shared/assets/locale/fr.json | 1 + shared/assets/locale/it.json | 1 + shared/assets/locale/ja-JP.json | 1 + shared/assets/locale/pt.json | 1 + shared/assets/locale/zh.json | 1 + 15 files changed, 114 insertions(+), 14 deletions(-) diff --git a/libs/components/src/lib/chat/chat.component.ts b/libs/components/src/lib/chat/chat.component.ts index 42b2828244e..522c97043db 100644 --- a/libs/components/src/lib/chat/chat.component.ts +++ b/libs/components/src/lib/chat/chat.component.ts @@ -297,7 +297,8 @@ export class ChatComponent extends AsyncHandler implements OnInit { public sendMessage() { if (!this.message()) return; - this._chat.sendMessage(this.message()); + // Keep the text in the input if the chat cannot connect + if (!this._chat.sendMessage(this.message())) return; this.message.set(''); this.height.set(56); setTimeout(() => this._input_el().nativeElement.focus(), 100); diff --git a/libs/components/src/lib/chat/chat.service.ts b/libs/components/src/lib/chat/chat.service.ts index 194fb27a373..ad13af17c53 100644 --- a/libs/components/src/lib/chat/chat.service.ts +++ b/libs/components/src/lib/chat/chat.service.ts @@ -4,7 +4,9 @@ import { OrganisationService, SettingsService, currentUser, + i18n, log, + notifyError, randomString, } from '@placeos/common'; import { apiKey, getModule, token } from '@placeos/ts-client'; @@ -111,23 +113,40 @@ export class ChatService extends AsyncHandler { /** * Sends a message to the chat. Opens a new connection first if the chat * has ended, for example after the idle timeout or `close()`. + * Returns `false` and shows an error if no chat system is set, so the + * caller can keep the message. An error also shows if the connection + * closes before it opens. */ - public sendMessage(message: string) { - if (!message) return; + public sendMessage(message: string): boolean { + if (!message) return false; const current = this._socket; if (!current || current.readyState >= WebSocket.CLOSING) { this._cleanup(current); this.startChat(); } - this._onMessage({ chat_id: '', message, user_id: currentUser().id }); const socket = this._socket; - // Sending before the socket opens throws, so wait for it to open - if (socket?.readyState === WebSocket.CONNECTING) { - socket.addEventListener('open', () => socket.send(message), { - once: true, - }); - } else socket?.send(message); + if (!socket) { + notifyError(i18n('APP.WORKPLACE.CHAT_SEND_FAILED')); + return false; + } + this._onMessage({ chat_id: '', message, user_id: currentUser().id }); + // Sending before the socket opens throws, so wait for it to open. + // A socket that fails before it opens always fires `close`. + if (socket.readyState === WebSocket.CONNECTING) { + const failed = () => + notifyError(i18n('APP.WORKPLACE.CHAT_SEND_FAILED')); + socket.addEventListener( + 'open', + () => { + socket.removeEventListener('close', failed); + socket.send(message); + }, + { once: true }, + ); + socket.addEventListener('close', failed, { once: true }); + } else socket.send(message); + return true; } private _timeoutSocket(delay = 55 * 1000) { diff --git a/libs/components/src/tests/chat.component.spec.ts b/libs/components/src/tests/chat.component.spec.ts index 903e589a32a..59b82f32f5a 100644 --- a/libs/components/src/tests/chat.component.spec.ts +++ b/libs/components/src/tests/chat.component.spec.ts @@ -14,7 +14,7 @@ describe('ChatComponent', () => { chat_hint: signal(''), messages: signal([]), progress: signal(null), - sendMessage: vi.fn(), + sendMessage: vi.fn(() => true), close: vi.fn(), }; @@ -69,6 +69,16 @@ describe('ChatComponent', () => { vi.advanceTimersByTime(200); }); + it('should keep the message when the chat cannot connect', () => { + chat_enabled.set(true); + spectator.component.show.set(true); + spectator.detectChanges(); + chat_mock.sendMessage.mockReturnValueOnce(false); + spectator.component.message.set('Hello there'); + spectator.component.sendMessage(); + expect(spectator.component.message()).toBe('Hello there'); + }); + it('should toggle the progress detail when tapped', () => { chat_enabled.set(true); spectator.component.show.set(true); diff --git a/libs/components/src/tests/chat.service.spec.ts b/libs/components/src/tests/chat.service.spec.ts index 2d1792b7ed8..0f1fed4986f 100644 --- a/libs/components/src/tests/chat.service.spec.ts +++ b/libs/components/src/tests/chat.service.spec.ts @@ -3,7 +3,11 @@ import { createServiceFactory, SpectatorService, } from '@ngneat/spectator/vitest'; -import { OrganisationService, SettingsService } from '@placeos/common'; +import { + OrganisationService, + setNotifyOutlet, + SettingsService, +} from '@placeos/common'; vi.mock('@placeos/ts-client', { spy: true }); @@ -12,14 +16,23 @@ import { ChatService } from '../lib/chat/chat.service'; /** WebSocket stand-in that records instances */ class FakeSocket { + public static readonly CONNECTING = 0; + public static readonly OPEN = 1; + public static readonly CLOSING = 2; public static instances: FakeSocket[] = []; - public readyState = 1; + public readyState = FakeSocket.OPEN; public onmessage: ((e: MessageEvent) => void) | null = null; public onerror: ((e: Event) => void) | null = null; public onclose: (() => void) | null = null; public close = vi.fn(); public send = vi.fn(); - public addEventListener = vi.fn(); + public listeners: Record void> = {}; + public addEventListener = vi.fn( + (name: string, fn: () => void) => (this.listeners[name] = fn), + ); + public removeEventListener = vi.fn( + (name: string) => delete this.listeners[name], + ); constructor(public url: string) { FakeSocket.instances.push(this); } @@ -42,7 +55,18 @@ describe('ChatService', () => { ], }); + // Workspace modules cannot be mocked here, so spy on the snackbar outlet + // that `notifyError` uses + const snackbar = { + open: vi.fn(() => ({ + onAction: () => ({ subscribe: () => undefined }), + dismiss: () => undefined, + })), + }; + beforeEach(() => { + snackbar.open.mockClear(); + setNotifyOutlet(snackbar as never); FakeSocket.instances = []; (window as unknown as { WebSocket: unknown }).WebSocket = FakeSocket; vi.mocked(client.getModule).mockImplementation( @@ -87,4 +111,39 @@ describe('ChatService', () => { expect(FakeSocket.instances).toHaveLength(2); expect(FakeSocket.instances[1].send).toHaveBeenCalledWith('Hello'); }); + + it('should not show a message that it cannot send', () => { + spectator.service.setBinding(''); + + expect(spectator.service.sendMessage('Hello')).toBe(false); + + expect(FakeSocket.instances).toHaveLength(0); + expect(spectator.service.messages()).toEqual([]); + expect(snackbar.open).toHaveBeenCalled(); + }); + + it('should show an error if the chat closes before it opens', () => { + spectator.service.startChat(); + const socket = FakeSocket.instances[0]; + socket.readyState = FakeSocket.CONNECTING; + + expect(spectator.service.sendMessage('Hello')).toBe(true); + socket.listeners['close'](); + + expect(socket.send).not.toHaveBeenCalled(); + expect(snackbar.open).toHaveBeenCalled(); + }); + + it('should send the message once the chat opens', () => { + spectator.service.startChat(); + const socket = FakeSocket.instances[0]; + socket.readyState = FakeSocket.CONNECTING; + + spectator.service.sendMessage('Hello'); + socket.listeners['open'](); + + expect(socket.send).toHaveBeenCalledWith('Hello'); + expect(socket.listeners['close']).toBeUndefined(); + expect(snackbar.open).not.toHaveBeenCalled(); + }); }); diff --git a/shared/assets/locale/ar.json b/shared/assets/locale/ar.json index 7ede6342caa..113fb566b48 100644 --- a/shared/assets/locale/ar.json +++ b/shared/assets/locale/ar.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "المساعد", "CHAT_WAITING": "في انتظار الرد...", "CHAT_MESSAGE_PLACEHOLDER": "رسالة جديدة...", + "CHAT_SEND_FAILED": "تعذر إرسال الرسالة. المساعد غير متاح.", "DEAL_COUNT_1": "{{ count }} Offer", "DEAL_COUNT": "{{ count }} Offers", "SORT_BY_DATE_DESC": "ترتيب حسب التاريخ (الأحدث أولاً)", diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 22281f45f02..68f184eb9d4 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -2050,6 +2050,7 @@ "CHAT_ASSISTANT": "Assistant", "CHAT_WAITING": "Waiting for a reply...", "CHAT_MESSAGE_PLACEHOLDER": "New message...", + "CHAT_SEND_FAILED": "Unable to send the message. The assistant is not available.", "DEAL_COUNT_1": "{{ count }} Offer", "DEAL_COUNT": "{{ count }} Offers", "SORT_BY_DATE_DESC": "Sort by Date (Newest First)", diff --git a/shared/assets/locale/en-GB.json b/shared/assets/locale/en-GB.json index e2749ba8775..3c2186d5726 100644 --- a/shared/assets/locale/en-GB.json +++ b/shared/assets/locale/en-GB.json @@ -967,6 +967,7 @@ "CHAT_ASSISTANT": "Assistant", "CHAT_WAITING": "Waiting for a reply...", "CHAT_MESSAGE_PLACEHOLDER": "New message...", + "CHAT_SEND_FAILED": "Unable to send the message. The assistant is not available.", "DEAL_COUNT_1": "{{ count }} Offer", "DEAL_COUNT": "{{ count }} Offers", "SORT_BY_DATE_DESC": "Sort by Date (Newest First)", diff --git a/shared/assets/locale/en-US.json b/shared/assets/locale/en-US.json index 67fe55b6a87..7be8e86dc86 100644 --- a/shared/assets/locale/en-US.json +++ b/shared/assets/locale/en-US.json @@ -967,6 +967,7 @@ "CHAT_ASSISTANT": "Assistant", "CHAT_WAITING": "Waiting for a reply...", "CHAT_MESSAGE_PLACEHOLDER": "New message...", + "CHAT_SEND_FAILED": "Unable to send the message. The assistant is not available.", "DEAL_COUNT_1": "{{ count }} Offer", "DEAL_COUNT": "{{ count }} Offers", "SORT_BY_DATE_DESC": "Sort by Date (Newest First)", diff --git a/shared/assets/locale/es.json b/shared/assets/locale/es.json index 31ee0b172df..c9025bef109 100644 --- a/shared/assets/locale/es.json +++ b/shared/assets/locale/es.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "Asistente", "CHAT_WAITING": "Esperando una respuesta...", "CHAT_MESSAGE_PLACEHOLDER": "Nuevo mensaje...", + "CHAT_SEND_FAILED": "No se pudo enviar el mensaje. El asistente no está disponible.", "DEAL_COUNT_1": "{{ count }} Oferta", "DEAL_COUNT": "{{ count }} Ofertas", "SORT_BY_DATE_DESC": "Ordenar por Fecha (Más Reciente Primero)", diff --git a/shared/assets/locale/fr-CA.json b/shared/assets/locale/fr-CA.json index c6fb9e202a8..c583e3693a4 100644 --- a/shared/assets/locale/fr-CA.json +++ b/shared/assets/locale/fr-CA.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "Assistant", "CHAT_WAITING": "En attente d'une réponse...", "CHAT_MESSAGE_PLACEHOLDER": "Nouveau message...", + "CHAT_SEND_FAILED": "Impossible d'envoyer le message. L'assistant n'est pas disponible.", "DEAL_COUNT_1": "{{ count }} offre", "DEAL_COUNT": "{{ count }} offres", "SORT_BY_DATE_DESC": "Trier par date (plus récent en premier)", diff --git a/shared/assets/locale/fr.json b/shared/assets/locale/fr.json index a3b863370ad..22f7edcd600 100644 --- a/shared/assets/locale/fr.json +++ b/shared/assets/locale/fr.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "Assistant", "CHAT_WAITING": "En attente d’une réponse...", "CHAT_MESSAGE_PLACEHOLDER": "Nouveau message...", + "CHAT_SEND_FAILED": "Impossible d'envoyer le message. L'assistant n'est pas disponible.", "DEAL_COUNT_1": "{{ count }} offre", "DEAL_COUNT": "{{ count }} offres", "SORT_BY_DATE_DESC": "Trier par date (plus récent d'abord)", diff --git a/shared/assets/locale/it.json b/shared/assets/locale/it.json index 9dde8706f20..48c00b395e7 100644 --- a/shared/assets/locale/it.json +++ b/shared/assets/locale/it.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "Assistente", "CHAT_WAITING": "In attesa di una risposta...", "CHAT_MESSAGE_PLACEHOLDER": "Nuovo messaggio...", + "CHAT_SEND_FAILED": "Impossibile inviare il messaggio. L'assistente non è disponibile.", "DEAL_COUNT_1": "{{ count }} Offerta", "DEAL_COUNT": "{{ count }} Offerte", "SORT_BY_DATE_DESC": "Ordina per Data (Prima i Più Recenti)", diff --git a/shared/assets/locale/ja-JP.json b/shared/assets/locale/ja-JP.json index e17a7a79902..44d50c861f1 100644 --- a/shared/assets/locale/ja-JP.json +++ b/shared/assets/locale/ja-JP.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "アシスタント", "CHAT_WAITING": "返信を待っています...", "CHAT_MESSAGE_PLACEHOLDER": "新しいメッセージ...", + "CHAT_SEND_FAILED": "メッセージを送信できません。アシスタントは利用できません。", "DEAL_COUNT_1": "{{ count }} Offer", "DEAL_COUNT": "{{ count }} Offers", "SORT_BY_DATE_DESC": "日付順(新しい順)", diff --git a/shared/assets/locale/pt.json b/shared/assets/locale/pt.json index a125deccf57..2c6b938a959 100644 --- a/shared/assets/locale/pt.json +++ b/shared/assets/locale/pt.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "Assistente", "CHAT_WAITING": "Aguardando resposta...", "CHAT_MESSAGE_PLACEHOLDER": "Nova mensagem...", + "CHAT_SEND_FAILED": "Não foi possível enviar a mensagem. O assistente não está disponível.", "DEAL_COUNT_1": "{{ count }} Oferta", "DEAL_COUNT": "{{ count }} Ofertas", "SORT_BY_DATE_DESC": "Ordenar por Data (Mais Recente Primeiro)", diff --git a/shared/assets/locale/zh.json b/shared/assets/locale/zh.json index eea73b47814..0cfd1c03764 100644 --- a/shared/assets/locale/zh.json +++ b/shared/assets/locale/zh.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "助手", "CHAT_WAITING": "等待回复...", "CHAT_MESSAGE_PLACEHOLDER": "新消息...", + "CHAT_SEND_FAILED": "无法发送消息。助手不可用。", "DEAL_COUNT_1": "{{ count }} 个优惠", "DEAL_COUNT": "{{ count }} 个优惠", "SORT_BY_DATE_DESC": "按日期排序(最新优先)",