From f94d7d84ebe86d921f8555b3203593dbf77dc830 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 11 Dec 2025 19:18:44 +1100 Subject: [PATCH 1/2] Reapply "feat: migrate bookable resources to use the assets API (PPT-2176)" This reverts commit 10a3325847ab0883a2a799507a2aeab429582b0f. --- .../src/app/desks/desk-modal.component.ts | 3 +- .../src/app/desks/desks-manage.component.ts | 6 +- .../src/app/desks/desks-state.service.ts | 327 +++++++-- .../src/app/desks/desks-topbar.component.ts | 20 + .../src/app/desks/desks.component.ts | 17 + .../src/app/lockers/locker-state.service.ts | 379 +++++++++-- .../app/lockers/locker-topbar.component.ts | 26 + .../src/app/parking/parking-state.service.ts | 463 ++++++++++--- .../app/parking/parking-topbar.component.ts | 27 + .../app/staff/emergency-contacts.component.ts | 66 +- .../app/staff/emergency-contacts.service.ts | 113 +++- apps/stagehand/src/app/alerts.component.ts | 9 +- .../src/app/dashboards/dashboards.service.ts | 17 +- .../src/app/push-notification.service.ts | 6 +- libs/bookings/src/index.ts | 3 +- libs/bookings/src/lib/booking-form.service.ts | 176 ++++- libs/bookings/src/lib/booking.utilities.ts | 269 ++++++-- .../desk-details.component.ts | 4 +- .../desk-select-modal/desk-list.component.ts | 2 +- libs/bookings/src/lib/parking.service.ts | 127 +++- .../src/lib/resource-assets.service.ts | 629 ++++++++++++++++++ libs/explore/src/lib/explore-desks.service.ts | 71 +- .../explore/src/lib/explore-search.service.ts | 96 ++- .../explore/src/lib/explore-spaces.service.ts | 10 +- shared/assets/locale/en-AU.json | 29 + 25 files changed, 2528 insertions(+), 367 deletions(-) create mode 100644 libs/bookings/src/lib/resource-assets.service.ts diff --git a/apps/concierge/src/app/desks/desk-modal.component.ts b/apps/concierge/src/app/desks/desk-modal.component.ts index ed3be345a0..edeb26f8dd 100644 --- a/apps/concierge/src/app/desks/desk-modal.component.ts +++ b/apps/concierge/src/app/desks/desk-modal.component.ts @@ -75,7 +75,7 @@ const CHARS = '0123456789ABCDEF'; {{ @@ -271,6 +271,7 @@ export class DeskModalComponent implements OnInit { public readonly form = new FormGroup({ id: new FormControl(``), + client_id: new FormControl(''), name: new FormControl('', [Validators.required]), map_id: new FormControl('', [Validators.required]), groups: new FormControl([]), diff --git a/apps/concierge/src/app/desks/desks-manage.component.ts b/apps/concierge/src/app/desks/desks-manage.component.ts index 2859dd2978..a6a2f29ca3 100644 --- a/apps/concierge/src/app/desks/desks-manage.component.ts +++ b/apps/concierge/src/app/desks/desks-manage.component.ts @@ -94,10 +94,10 @@ const QR_CODES = {}; } + @if (manage() && needs_migration()) { + + }
add } + @if (manage && needs_migration()) { + + }
@if (!manage) { @@ -310,6 +324,8 @@ export class DesksComponent extends AsyncHandler implements OnInit, OnDestroy { public manage = false; /** Signal for filters */ public readonly filters = this._state.filters; + /** Signal for migration status */ + public readonly needs_migration = this._state.needs_migration; /** Signal for levels for the active building */ public readonly levels = toSignal( combineLatest([ @@ -329,6 +345,7 @@ export class DesksComponent extends AsyncHandler implements OnInit, OnDestroy { public readonly refresh = () => this._state.refresh(); public readonly rejectAll = () => this._state.rejectAllDesks(); public readonly editDesk = () => this._state.editDesk(); + public readonly migrateDesks = () => this._state.migrateDesks(); /** Update active zones for desks */ public readonly updateZones = (zones: string[]) => { this._router.navigate([], { diff --git a/apps/concierge/src/app/lockers/locker-state.service.ts b/apps/concierge/src/app/lockers/locker-state.service.ts index 45f4fcc55a..572d3ad245 100644 --- a/apps/concierge/src/app/lockers/locker-state.service.ts +++ b/apps/concierge/src/app/lockers/locker-state.service.ts @@ -1,16 +1,21 @@ -import { inject, Injectable } from '@angular/core'; +import { inject, Injectable, signal } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { approveBooking, checkinBooking, + legacyLockerBankMapFn, + legacyLockerMapFn, loadLockerBanks, loadLockers, Locker, + LOCKER_ASSET_MAPPING, + LOCKER_BANK_ASSET_MAPPING, LockerBank, queryBookings, queryPagedBookings, rejectBooking, removeBooking, + ResourceAssetsService, saveBooking, } from '@placeos/bookings'; import { @@ -66,6 +71,7 @@ export class LockerStateService extends AsyncHandler { private _org = inject(OrganisationService); private _dialog = inject(MatDialog); private _settings = inject(SettingsService); + private _resourceAssets = inject(ResourceAssetsService); private _search = new BehaviorSubject(''); private _filters = new BehaviorSubject({}); @@ -73,6 +79,13 @@ export class LockerStateService extends AsyncHandler { private _locker_bookings: Booking[] = []; private _loading = new BehaviorSubject(''); private _change = new BehaviorSubject(0); + private _banks_need_migration = signal(false); + private _lockers_need_migration = signal(false); + + public readonly banks_need_migration = + this._banks_need_migration.asReadonly(); + public readonly lockers_need_migration = + this._lockers_need_migration.asReadonly(); /** List of available locker levels for the current building */ public levels = this._org.level_list.pipe( map((_) => { @@ -106,6 +119,7 @@ export class LockerStateService extends AsyncHandler { this._change, ]), () => this._settings.get('app.use_region'), + this._resourceAssets, ); public readonly lockers$: Observable = loadLockers( this._org, @@ -116,6 +130,7 @@ export class LockerStateService extends AsyncHandler { ]), this.lockers_banks$, () => this._settings.get('app.use_region'), + this._resourceAssets, ); public filtered_lockers = combineLatest([ @@ -283,6 +298,11 @@ export class LockerStateService extends AsyncHandler { constructor() { super(); this.setup_paging.subscribe(); + // Check migration status when building changes + this._org.active_building.subscribe(() => { + this.checkBanksMigrationStatus(); + this.checkLockersMigrationStatus(); + }); } public setSearch(value: string) { @@ -459,7 +479,7 @@ export class LockerStateService extends AsyncHandler { if (close) close(); } - /** Add or update a space in the available list */ + /** Add or update a locker bank in the available list */ public async editLockerBank(bank: LockerBank = {} as LockerBank) { const ref = this._dialog.open(LockerBankModalComponent, { data: bank, @@ -472,29 +492,58 @@ export class LockerStateService extends AsyncHandler { ]); if (state?.reason !== 'done') return; const zone = this._org.building.id; - const new_bank = { + const new_bank: LockerBank = { ...state.metadata, - zone, id: bank.id || `locker-bank-${randomInt(999_999)}`, }; - const banks = await nextValueFrom(this.lockers_banks$); - const idx = banks.findIndex((_) => _.id === new_bank.id); - if (idx >= 0) banks[idx] = new_bank; - else banks.push(new_bank); - const new_locker_list = banks.map((_) => ({ ..._ })); - for (const bank of new_locker_list) { - delete bank.lockers; + + const using_assets = await this._resourceAssets.isUsingAssetsAPI( + 'locker_banks', + zone, + ); + + try { + if (using_assets) { + const saved = await this._resourceAssets.saveResource( + 'locker_banks', + new_bank, + zone, + LOCKER_BANK_ASSET_MAPPING, + bank.id, + ); + if (saved) { + (new_bank as any).id = saved.id; + } + } else { + const banks = await nextValueFrom(this.lockers_banks$); + const idx = banks.findIndex( + (_) => _.id === (bank.id || new_bank.id), + ); + if (idx >= 0) banks[idx] = new_bank; + else banks.push(new_bank); + const new_locker_list = banks.map((_) => ({ ..._ })); + for (const b of new_locker_list) { + delete b.lockers; + } + await updateMetadata(zone, { + name: 'locker_banks', + details: new_locker_list, + description: 'List of available locker banks', + }).toPromise(); + } + } catch (e) { + notifyError( + i18n('APP.CONCIERGE.LOCKERS_BANK_SAVE_ERROR', { error: e }) || + `Failed to save locker bank: ${e}`, + ); + throw e; } - await updateMetadata(zone, { - name: 'locker_banks', - details: new_locker_list, - description: 'List of available locker banks', - }).toPromise(); + this._change.next(Date.now()); ref.close(); } - /** Add or update a space in the available list */ + /** Add or update a locker in the available list */ public async editLocker(bank: LockerBank, locker: Locker = {} as Locker) { const ref = this._dialog.open(LockerModalComponent, { data: { locker, bank }, @@ -507,14 +556,12 @@ export class LockerStateService extends AsyncHandler { ]); if (state?.reason !== 'done') return; const zone = this._org.building.id; - const new_locker = { + const new_locker: Locker = { ...state.metadata, bank_id: bank.id, - zone, id: locker.id || `locker-${zone}.${randomInt(999_999)}`, }; - const lockers = await nextValueFrom(this.lockers$); - const idx = lockers.findIndex((_) => _.id === new_locker.id); + if ( locker.assigned_to && locker.assigned_to !== new_locker.assigned_to @@ -530,7 +577,7 @@ export class LockerStateService extends AsyncHandler { new Booking({ user_id: new_locker.assigned_to, user_email: new_locker.assigned_to, - user_name: new_locker?.assigned_name, + user_name: (new_locker as any)?.assigned_name, booking_start: getUnixTime(date), booking_end: getUnixTime(addHours(date, 20)), type: 'locker', @@ -548,8 +595,6 @@ export class LockerStateService extends AsyncHandler { this._org.organisation.id, this._org.region?.id, this._org.building?.id, - new_locker.zone?.id, - new_locker.zone, ...(bank?.zones || []), ]).filter((_) => !!_), tags: bank?.tags || [], @@ -561,18 +606,50 @@ export class LockerStateService extends AsyncHandler { }), ).toPromise(); } - if (idx >= 0) lockers[idx] = new_locker; - else lockers.push(new_locker); - const new_locker_list = lockers; - for (const locker of new_locker_list) { - if (locker.bank) delete locker.bank; - if (locker.zone) delete locker.zone; + + const using_assets = await this._resourceAssets.isUsingAssetsAPI( + 'lockers', + zone, + ); + + try { + if (using_assets) { + const saved = await this._resourceAssets.saveResource( + 'lockers', + new_locker, + zone, + LOCKER_ASSET_MAPPING, + locker.id, + ); + if (saved) { + (new_locker as any).id = saved.id; + } + } else { + const lockers = await nextValueFrom(this.lockers$); + const idx = lockers.findIndex( + (_) => _.id === (locker.id || new_locker.id), + ); + if (idx >= 0) lockers[idx] = new_locker; + else lockers.push(new_locker); + const new_locker_list = lockers; + for (const l of new_locker_list) { + if (l.bank) delete l.bank; + if (l.zone) delete l.zone; + } + await updateMetadata(zone, { + name: 'lockers', + details: new_locker_list, + description: 'List of available lockers', + }).toPromise(); + } + } catch (e) { + notifyError( + i18n('APP.CONCIERGE.LOCKERS_SAVE_ERROR', { error: e }) || + `Failed to save locker: ${e}`, + ); + throw e; } - await updateMetadata(zone, { - name: 'lockers', - details: new_locker_list, - description: 'List of available lockers', - }).toPromise(); + this._change.next(Date.now()); ref.close(); } @@ -591,24 +668,33 @@ export class LockerStateService extends AsyncHandler { if (state?.reason !== 'done') return; state.loading(i18n('APP.CONCIERGE.LOCKERS_BANK_REMOVE_LOADING')); const zone = this._org.building.id; - const banks = await nextValueFrom(this.lockers_banks$); - await updateMetadata(zone, { - name: 'locker_banks', - details: banks.filter((_) => _.id !== bank.id), - description: 'List of available locker banks', - }) - .toPromise() - .catch((e) => { - notifyError( - i18n('APP.CONCIERGE.LOCKERS_BANK_REMOVE_ERROR', { - error: e, - }), - ); - throw e; - }); + + const using_assets = await this._resourceAssets.isUsingAssetsAPI( + 'locker_banks', + zone, + ); + + try { + if (using_assets) { + await this._resourceAssets.deleteResource(bank.id); + } else { + const banks = await nextValueFrom(this.lockers_banks$); + await updateMetadata(zone, { + name: 'locker_banks', + details: banks.filter((_) => _.id !== bank.id), + description: 'List of available locker banks', + }).toPromise(); + } + notifySuccess(i18n('APP.CONCIERGE.LOCKERS_BANK_REMOVE_SUCCESS')); + this._change.next(Date.now()); + } catch (e) { + notifyError( + i18n('APP.CONCIERGE.LOCKERS_BANK_REMOVE_ERROR', { + error: e, + }), + ); + } state.close(); - notifySuccess(i18n('APP.CONCIERGE.LOCKERS_BANK_REMOVE_SUCCESS')); - this._change.next(Date.now()); } public async removeLocker(locker: Locker) { @@ -625,23 +711,32 @@ export class LockerStateService extends AsyncHandler { if (state?.reason !== 'done') return; state.loading(i18n('APP.CONCIERGE.LOCKERS_REMOVE_LOADING')); const zone = this._org.building.id; - const lockers = await nextValueFrom(this.lockers$); this._clearAssignedBooking(locker); - await updateMetadata(zone, { - name: 'lockers', - details: lockers.filter((_) => _.id !== locker.id), - description: 'List of available lockers', - }) - .toPromise() - .catch((e) => { - notifyError( - i18n('APP.CONCIERGE.LOCKERS_REMOVE_ERROR', { error: e }), - ); - throw e; - }); + + const using_assets = await this._resourceAssets.isUsingAssetsAPI( + 'lockers', + zone, + ); + + try { + if (using_assets) { + await this._resourceAssets.deleteResource(locker.id); + } else { + const lockers = await nextValueFrom(this.lockers$); + await updateMetadata(zone, { + name: 'lockers', + details: lockers.filter((_) => _.id !== locker.id), + description: 'List of available lockers', + }).toPromise(); + } + notifySuccess(i18n('APP.CONCIERGE.LOCKERS_REMOVE_SUCCESS')); + this._change.next(Date.now()); + } catch (e) { + notifyError( + i18n('APP.CONCIERGE.LOCKERS_REMOVE_ERROR', { error: e }), + ); + } state.close(); - notifySuccess(i18n('APP.CONCIERGE.LOCKERS_REMOVE_SUCCESS')); - this._change.next(Date.now()); } public async editBooking( @@ -800,4 +895,154 @@ export class LockerStateService extends AsyncHandler { const filtered = booking_list.filter((_) => _.asset_id === locker.id); await Promise.all(filtered.map((_) => removeBooking(_.id).toPromise())); } + + /** Check and update migration status for locker banks */ + public async checkBanksMigrationStatus(): Promise { + const zone = this._org.building?.id; + if (!zone) return; + const needs_migration = await this._resourceAssets.needsMigration( + 'locker_banks', + zone, + ); + this._banks_need_migration.set(needs_migration); + } + + /** Check and update migration status for lockers */ + public async checkLockersMigrationStatus(): Promise { + const zone = this._org.building?.id; + if (!zone) return; + const needs_migration = await this._resourceAssets.needsMigration( + 'lockers', + zone, + ); + this._lockers_need_migration.set(needs_migration); + } + + /** Migrate locker banks from metadata to Assets API */ + public async migrateLockerBanks(): Promise { + const zone = this._org.building?.id; + if (!zone) { + notifyError('Please select a building to migrate locker banks.'); + return false; + } + + const resp = await openConfirmModal( + { + title: + i18n('APP.CONCIERGE.LOCKERS_BANKS_MIGRATE_TITLE') || + 'Migrate Locker Banks', + content: + i18n('APP.CONCIERGE.LOCKERS_BANKS_MIGRATE_MSG') || + `This will migrate all locker banks for this building to the Assets API. Continue?`, + icon: { + type: 'icon', + class: 'material-symbols-rounded', + content: 'sync', + }, + }, + this._dialog, + ); + + if (resp.reason !== 'done') return false; + + resp.loading( + i18n('APP.CONCIERGE.LOCKERS_BANKS_MIGRATE_LOADING') || + 'Migrating locker banks...', + ); + + try { + const result = await this._resourceAssets.migrateFromMetadata( + 'locker_banks', + 'locker_banks', + zone, + LOCKER_BANK_ASSET_MAPPING, + legacyLockerBankMapFn, + ); + resp.close(); + this._banks_need_migration.set(false); + this._change.next(Date.now()); + return result; + } catch (e) { + notifyError( + i18n('APP.CONCIERGE.LOCKERS_BANKS_MIGRATE_ERROR', { + error: e, + }) || `Migration failed: ${e}`, + ); + resp.close(); + return false; + } + } + + /** Migrate lockers from metadata to Assets API */ + public async migrateLockers(): Promise { + const zone = this._org.building?.id; + if (!zone) { + notifyError('Please select a building to migrate lockers.'); + return false; + } + + const resp = await openConfirmModal( + { + title: + i18n('APP.CONCIERGE.LOCKERS_MIGRATE_TITLE') || + 'Migrate Lockers', + content: + i18n('APP.CONCIERGE.LOCKERS_MIGRATE_MSG') || + `This will migrate all lockers for this building to the Assets API. Continue?`, + icon: { + type: 'icon', + class: 'material-symbols-rounded', + content: 'sync', + }, + }, + this._dialog, + ); + + if (resp.reason !== 'done') return false; + + resp.loading( + i18n('APP.CONCIERGE.LOCKERS_MIGRATE_LOADING') || + 'Migrating lockers...', + ); + + try { + // Get current banks to build old ID -> new ID mapping + const banks = await nextValueFrom(this.lockers_banks$); + const bankIdMap = new Map(); + for (const bank of banks) { + const client_id = (bank as any).client_id; + if (client_id && client_id !== bank.id) { + bankIdMap.set(client_id, bank.id); + } + } + + // Custom migration that updates bank_id references + const result = + await this._resourceAssets.migrateFromMetadataWithTransform( + 'lockers', + 'lockers', + zone, + LOCKER_ASSET_MAPPING, + (item: any, zone_id: string) => { + const locker = legacyLockerMapFn(item, zone_id); + // Update bank_id to new Asset ID if mapping exists + if (locker.bank_id && bankIdMap.has(locker.bank_id)) { + locker.bank_id = bankIdMap.get(locker.bank_id); + } + return locker; + }, + ); + resp.close(); + this._lockers_need_migration.set(false); + this._change.next(Date.now()); + return result; + } catch (e) { + notifyError( + i18n('APP.CONCIERGE.LOCKERS_MIGRATE_ERROR', { error: e }) || + `Migration failed: ${e}`, + ); + resp.close(); + return false; + } + } } diff --git a/apps/concierge/src/app/lockers/locker-topbar.component.ts b/apps/concierge/src/app/lockers/locker-topbar.component.ts index f53a91ad6e..931a5ae407 100644 --- a/apps/concierge/src/app/lockers/locker-topbar.component.ts +++ b/apps/concierge/src/app/lockers/locker-topbar.component.ts @@ -133,6 +133,21 @@ import { LockerStateService } from './locker-state.service'; lock_open } + @if ( + path() === 'manage' && + (banks_need_migration() || lockers_need_migration()) + ) { + + } @if (path() === 'events' || path() === 'map') { } @@ -179,6 +194,9 @@ export class LockersTopbarComponent extends AsyncHandler implements OnInit { public readonly levels = this._state.levels; /** Options set for week view */ public readonly options = this._state.filters; + /** Migration status signals */ + public readonly banks_need_migration = this._state.banks_need_migration; + public readonly lockers_need_migration = this._state.lockers_need_migration; /** Set filtered date */ public readonly setDate = (d) => this._state.setFilters({ date: d }); /** Set filter string */ @@ -186,6 +204,14 @@ export class LockersTopbarComponent extends AsyncHandler implements OnInit { public readonly newLockerBank = () => this._state.editLockerBank(); public readonly releaseAllLockers = () => this._state.releaseAllLockers(true); + public async migrateAll() { + if (this.banks_need_migration()) { + await this._state.migrateLockerBanks(); + } + if (this.lockers_need_migration()) { + await this._state.migrateLockers(); + } + } /** List of levels for the active building */ public readonly updateZones = (z) => { if (!this._router.url.includes('lockers')) return; diff --git a/apps/concierge/src/app/parking/parking-state.service.ts b/apps/concierge/src/app/parking/parking-state.service.ts index 6806b6b393..7f5c8983c5 100644 --- a/apps/concierge/src/app/parking/parking-state.service.ts +++ b/apps/concierge/src/app/parking/parking-state.service.ts @@ -1,4 +1,4 @@ -import { inject, Injectable } from '@angular/core'; +import { inject, Injectable, signal } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { approveBooking, @@ -9,9 +9,11 @@ import { rejectBooking, rejectBookingInstance, removeBooking, + ResourceAssetsService, saveBooking, } from '@placeos/bookings'; import { + Asset, AsyncHandler, Booking, i18n, @@ -26,11 +28,12 @@ import { User, } from '@placeos/common'; import { openConfirmModal } from '@placeos/components'; -import { showMetadata, updateMetadata } from '@placeos/ts-client'; +import { updateMetadata } from '@placeos/ts-client'; import { UserPipe } from '@placeos/users'; import { addHours, endOfDay, getUnixTime, set, startOfDay } from 'date-fns'; import { BehaviorSubject, combineLatest, of } from 'rxjs'; import { + catchError, debounceTime, filter, first, @@ -43,6 +46,90 @@ import { ParkingBookingModalComponent } from './parking-booking-modal.component' import { ParkingSpaceModalComponent } from './parking-space-modal.component'; import { ParkingUserModalComponent } from './parking-user-modal.component'; +/** ParkingSpace to Asset mapping */ +const PARKING_SPACE_ASSET_MAPPING = { + assetToResource: (asset: Asset, zone_id?: string): ParkingSpace => { + const other_data = asset.other_data as Record; + return { + id: asset.id, + map_id: other_data?.map_id || asset.id, + name: asset.identifier || '', + notes: other_data?.notes || asset.notes || '', + assigned_to: asset.assigned_to || '', + zone_id: zone_id || asset.zone_id, + }; + }, + resourceToAsset: ( + space: ParkingSpace, + asset_type_id: string, + zone_id: string, + zones: string[], + ): Partial => ({ + id: space.id?.startsWith('temp-') ? undefined : space.id, + asset_type_id, + identifier: space.name, + assigned_to: space.assigned_to || '', + notes: space.notes || '', + zone_id, + zones, + other_data: { + map_id: space.map_id || space.id, + notes: space.notes || '', + } as Record, + }), +}; + +/** ParkingUser to Asset mapping */ +const PARKING_USER_ASSET_MAPPING = { + assetToResource: (asset: Asset, zone_id?: string): ParkingUser => { + const other_data = asset.other_data as Record; + return { + id: asset.id, + name: asset.identifier || '', + email: other_data?.email || '', + car_model: other_data?.car_model || '', + car_colour: other_data?.car_colour || '', + plate_number: other_data?.plate_number || '', + phone: other_data?.phone || '', + notes: other_data?.notes || asset.notes || '', + deny: other_data?.deny ?? false, + }; + }, + resourceToAsset: ( + user: ParkingUser, + asset_type_id: string, + zone_id: string, + zones: string[], + ): Partial => ({ + id: user.id?.startsWith('temp-') ? undefined : user.id, + asset_type_id, + identifier: user.name, + notes: user.notes || '', + zone_id, + zones, + other_data: { + email: user.email || '', + car_model: user.car_model || '', + car_colour: user.car_colour || '', + plate_number: user.plate_number || '', + phone: user.phone || '', + notes: user.notes || '', + deny: user.deny ?? false, + } as Record, + }), +}; + +/** Legacy metadata to ParkingSpace mapping */ +const legacySpaceMapFn = (item: any, zone_id: string): ParkingSpace => ({ + ...item, + zone_id, +}); + +/** Legacy metadata to ParkingUser mapping */ +const legacyUserMapFn = (item: any, zone_id: string): ParkingUser => ({ + ...item, +}); + export interface ParkingOptions { date: number; search: string; @@ -79,6 +166,7 @@ export class ParkingStateService extends AsyncHandler { private _org = inject(OrganisationService); private _dialog = inject(MatDialog); private _settings = inject(SettingsService); + private _resourceAssets = inject(ResourceAssetsService); private _poll = new BehaviorSubject(0); private _change = new BehaviorSubject(0); @@ -88,6 +176,13 @@ export class ParkingStateService extends AsyncHandler { zones: [], }); private _loading = new BehaviorSubject([]); + private _spaces_need_migration = signal(false); + private _users_need_migration = signal(false); + + public readonly spaces_need_migration = + this._spaces_need_migration.asReadonly(); + public readonly users_need_migration = + this._users_need_migration.asReadonly(); /** List of available parking levels for the current building */ public levels = combineLatest([ this._org.active_region, @@ -122,25 +217,23 @@ export class ParkingStateService extends AsyncHandler { this._change, ]).pipe( switchMap(([levels, options]) => { - if (!(options.zones[0] || levels[0]?.id)) { + const zone_id = options.zones[0] || levels[0]?.id; + if (!zone_id) { return of([] as ParkingSpace[]); } this._loading.next([...this._loading.getValue(), 'spaces']); - return showMetadata( - options.zones[0] || levels[0]?.id, - 'parking-spaces', - ).pipe( - map( - ({ details }) => - (details instanceof Array ? details : []).map( - (space) => - ({ - ...space, - zone_id: options.zones[0] || levels[0]?.id, - }) as ParkingSpace, - ) as ParkingSpace[], - ), - ); + // Check migration status + this._checkSpacesMigrationStatus(zone_id); + // Try Assets API first, fallback to metadata + return this._resourceAssets + .loadWithFallback$( + 'parking-spaces', + 'parking-spaces', + zone_id, + PARKING_SPACE_ASSET_MAPPING, + legacySpaceMapFn, + ) + .pipe(catchError(() => of([] as ParkingSpace[]))); }), tap(() => this._loading.next( @@ -149,7 +242,7 @@ export class ParkingStateService extends AsyncHandler { ), shareReplay(1), ); - /** List of parking spaces for the current building/level */ + /** List of parking users for the current building */ public users = combineLatest([ this._org.active_building, this._change, @@ -157,14 +250,19 @@ export class ParkingStateService extends AsyncHandler { filter(([bld]) => !!bld?.id), switchMap(([bld]) => { this._loading.next([...this._loading.getValue(), 'users']); - return showMetadata(bld.id, 'parking-users'); + // Check migration status + this._checkUsersMigrationStatus(bld.id); + // Try Assets API first, fallback to metadata + return this._resourceAssets + .loadWithFallback$( + 'parking-users', + 'parking-users', + bld.id, + PARKING_USER_ASSET_MAPPING, + legacyUserMapFn, + ) + .pipe(catchError(() => of([] as ParkingUser[]))); }), - map( - (metadata) => - (metadata.details instanceof Array - ? metadata.details - : []) as ParkingUser[], - ), tap(() => this._loading.next( this._loading.getValue().filter((_) => _ !== 'users'), @@ -252,13 +350,12 @@ export class ParkingStateService extends AsyncHandler { this._options.getValue().zones[0] || space.zone_id || this._org.levelsForBuilding()[0]?.id; - const new_space = { + const new_space: ParkingSpace = { ...state.metadata, - zone, + zone_id: zone, id: state.metadata.id || `parking-${zone}.${randomInt(999_999)}`, }; const spaces = await nextValueFrom(this.spaces); - const idx = spaces.findIndex((_) => _.id === space.id); let recreate = false; if ( space.assigned_to && @@ -300,9 +397,7 @@ export class ParkingStateService extends AsyncHandler { this._org.organisation.id, this._org.region?.id, this._org.building?.id, - new_space.zone_id || - new_space.zone?.id || - new_space.zone, + new_space.zone_id, ]), extension_data: { asset_name: new_space.name, @@ -312,14 +407,42 @@ export class ParkingStateService extends AsyncHandler { }), ).toPromise(); } - if (idx >= 0) spaces[idx] = new_space; - else spaces.push(new_space); - const new_space_list = spaces; - await updateMetadata(zone, { - name: 'parking-spaces', - details: new_space_list, - description: 'List of available parking spaces', - }).toPromise(); + + const using_assets = await this._resourceAssets.isUsingAssetsAPI( + 'parking-spaces', + zone, + ); + + try { + if (using_assets) { + const saved = await this._resourceAssets.saveResource( + 'parking-spaces', + new_space, + zone, + PARKING_SPACE_ASSET_MAPPING, + space.id, + ); + if (saved) { + new_space.id = saved.id; + } + } else { + const idx = spaces.findIndex((_) => _.id === space.id); + if (idx >= 0) spaces[idx] = new_space; + else spaces.push(new_space); + await updateMetadata(zone, { + name: 'parking-spaces', + details: spaces, + description: 'List of available parking spaces', + }).toPromise(); + } + } catch (e) { + notifyError( + i18n('APP.CONCIERGE.PARKING_SPACE_SAVE_ERROR', { error: e }) || + `Failed to save parking space: ${e}`, + ); + throw e; + } + this._change.next(Date.now()); ref.close(); } @@ -336,18 +459,37 @@ export class ParkingStateService extends AsyncHandler { ); if (state?.reason !== 'done') return; state.loading('Removing parking space...'); - const zone = this._options.getValue().zones[0]; - const spaces = await nextValueFrom(this.spaces); + const zone = this._options.getValue().zones[0] || space.zone_id; this._clearAssignedBooking(space); - await updateMetadata(zone, { - name: 'parking-spaces', - details: spaces.filter((_) => _.id !== space.id), - description: 'List of available parking spaces', - }).toPromise(); + + const using_assets = await this._resourceAssets.isUsingAssetsAPI( + 'parking-spaces', + zone, + ); + + try { + if (using_assets) { + await this._resourceAssets.deleteResource(space.id); + } else { + const spaces = await nextValueFrom(this.spaces); + await updateMetadata(zone, { + name: 'parking-spaces', + details: spaces.filter((_) => _.id !== space.id), + description: 'List of available parking spaces', + }).toPromise(); + } + this._change.next(Date.now()); + } catch (e) { + notifyError( + i18n('APP.CONCIERGE.PARKING_SPACE_DELETE_ERROR', { + error: e, + }) || `Failed to delete parking space: ${e}`, + ); + } state.close(); } - /** Add or update a space in the available list */ + /** Add or update a user in the available list */ public async editUser(user?: ParkingUser) { const ref = this._dialog.open(ParkingUserModalComponent, { data: user, @@ -360,25 +502,55 @@ export class ParkingStateService extends AsyncHandler { ]); if (state?.reason !== 'done') return; const zone = this._org.building.id; - const new_user = { + const new_user: ParkingUser = { ...state.metadata, id: state.metadata.id || `P:USR-${randomInt(999_999)}`, }; - if ('user' in new_user) delete new_user.user; - const users = await nextValueFrom(this.users); - const idx = users.findIndex((_) => _.id === new_user.id); - if (idx >= 0) users[idx] = new_user; - else users.push(new_user); - await updateMetadata(zone, { - name: 'parking-users', - details: users, - description: 'List of available parking users', - }).toPromise(); + if ('user' in new_user) delete (new_user as any).user; + + const using_assets = await this._resourceAssets.isUsingAssetsAPI( + 'parking-users', + zone, + ); + + try { + if (using_assets) { + const saved = await this._resourceAssets.saveResource( + 'parking-users', + new_user, + zone, + PARKING_USER_ASSET_MAPPING, + user?.id, + ); + if (saved) { + new_user.id = saved.id; + } + } else { + const users = await nextValueFrom(this.users); + const idx = users.findIndex( + (_) => _.id === (user?.id || new_user.id), + ); + if (idx >= 0) users[idx] = new_user; + else users.push(new_user); + await updateMetadata(zone, { + name: 'parking-users', + details: users, + description: 'List of available parking users', + }).toPromise(); + } + } catch (e) { + notifyError( + i18n('APP.CONCIERGE.PARKING_USER_SAVE_ERROR', { error: e }) || + `Failed to save parking user: ${e}`, + ); + throw e; + } + this._change.next(Date.now()); ref.close(); } - /** Remove the given space from the available list */ + /** Remove the given user from the available list */ public async removeUser(user: ParkingUser) { const state = await openConfirmModal( { @@ -393,24 +565,33 @@ export class ParkingStateService extends AsyncHandler { if (state?.reason !== 'done') return; state.loading(i18n('APP.CONCIERGE.PARKING_USER_REMOVE_LOADING')); const zone = this._org.building.id; - const users = await nextValueFrom(this.users); - await updateMetadata(zone, { - name: 'parking-users', - details: users.filter((_) => _.id !== user.id), - description: 'List of available parking users', - }) - .toPromise() - .catch((e) => { - notifyError( - i18n('APP.CONCIERGE.PARKING_USER_REMOVE_ERROR', { - error: e, - }), - ); - throw e; - }); + + const using_assets = await this._resourceAssets.isUsingAssetsAPI( + 'parking-users', + zone, + ); + + try { + if (using_assets) { + await this._resourceAssets.deleteResource(user.id); + } else { + const users = await nextValueFrom(this.users); + await updateMetadata(zone, { + name: 'parking-users', + details: users.filter((_) => _.id !== user.id), + description: 'List of available parking users', + }).toPromise(); + } + notifySuccess(i18n('APP.CONCIERGE.PARKING_USER_REMOVE_SUCCESS')); + this._change.next(Date.now()); + } catch (e) { + notifyError( + i18n('APP.CONCIERGE.PARKING_USER_REMOVE_ERROR', { + error: e, + }), + ); + } state.close(); - notifySuccess(i18n('APP.CONCIERGE.PARKING_USER_REMOVE_SUCCESS')); - this._change.next(Date.now()); } public editReservation( @@ -536,4 +717,132 @@ export class ParkingStateService extends AsyncHandler { const filtered = booking_list.filter((_) => _.asset_id === space.id); await Promise.all(filtered.map((_) => removeBooking(_.id).toPromise())); } + + /** Check migration status for parking spaces */ + private async _checkSpacesMigrationStatus(zone_id: string): Promise { + const needs_migration = await this._resourceAssets.needsMigration( + 'parking-spaces', + zone_id, + ); + this._spaces_need_migration.set(needs_migration); + } + + /** Check migration status for parking users */ + private async _checkUsersMigrationStatus(zone_id: string): Promise { + const needs_migration = await this._resourceAssets.needsMigration( + 'parking-users', + zone_id, + ); + this._users_need_migration.set(needs_migration); + } + + /** Migrate parking spaces from metadata to Assets API */ + public async migrateSpaces(): Promise { + const zone = + this._options.getValue().zones[0] || + (await nextValueFrom(this.levels))[0]?.id; + if (!zone) { + notifyError('Please select a parking level to migrate.'); + return false; + } + + const resp = await openConfirmModal( + { + title: + i18n('APP.CONCIERGE.PARKING_SPACES_MIGRATE_TITLE') || + 'Migrate Parking Spaces', + content: + i18n('APP.CONCIERGE.PARKING_SPACES_MIGRATE_MSG') || + `This will migrate all parking spaces for this level to the Assets API. Continue?`, + icon: { + type: 'icon', + class: 'material-symbols-rounded', + content: 'sync', + }, + }, + this._dialog, + ); + + if (resp.reason !== 'done') return false; + + resp.loading( + i18n('APP.CONCIERGE.PARKING_SPACES_MIGRATE_LOADING') || + 'Migrating parking spaces...', + ); + + try { + const result = await this._resourceAssets.migrateFromMetadata( + 'parking-spaces', + 'parking-spaces', + zone, + PARKING_SPACE_ASSET_MAPPING, + legacySpaceMapFn, + ); + resp.close(); + this._change.next(Date.now()); + return result; + } catch (e) { + notifyError( + i18n('APP.CONCIERGE.PARKING_SPACES_MIGRATE_ERROR', { + error: e, + }) || `Migration failed: ${e}`, + ); + resp.close(); + return false; + } + } + + /** Migrate parking users from metadata to Assets API */ + public async migrateUsers(): Promise { + const zone = this._org.building?.id; + if (!zone) { + notifyError('Please select a building to migrate parking users.'); + return false; + } + + const resp = await openConfirmModal( + { + title: + i18n('APP.CONCIERGE.PARKING_USERS_MIGRATE_TITLE') || + 'Migrate Parking Users', + content: + i18n('APP.CONCIERGE.PARKING_USERS_MIGRATE_MSG') || + `This will migrate all parking users for this building to the Assets API. Continue?`, + icon: { + type: 'icon', + class: 'material-symbols-rounded', + content: 'sync', + }, + }, + this._dialog, + ); + + if (resp.reason !== 'done') return false; + + resp.loading( + i18n('APP.CONCIERGE.PARKING_USERS_MIGRATE_LOADING') || + 'Migrating parking users...', + ); + + try { + const result = await this._resourceAssets.migrateFromMetadata( + 'parking-users', + 'parking-users', + zone, + PARKING_USER_ASSET_MAPPING, + legacyUserMapFn, + ); + resp.close(); + this._change.next(Date.now()); + return result; + } catch (e) { + notifyError( + i18n('APP.CONCIERGE.PARKING_USERS_MIGRATE_ERROR', { + error: e, + }) || `Migration failed: ${e}`, + ); + resp.close(); + return false; + } + } } diff --git a/apps/concierge/src/app/parking/parking-topbar.component.ts b/apps/concierge/src/app/parking/parking-topbar.component.ts index c4ccf0bbd5..6b745c3161 100644 --- a/apps/concierge/src/app/parking/parking-topbar.component.ts +++ b/apps/concierge/src/app/parking/parking-topbar.component.ts @@ -168,6 +168,21 @@ import { ParkingStateService } from './parking-state.service'; lock_open } + @if ( + (view() === 'spaces' || view() === 'users') && + (spaces_need_migration() || users_need_migration()) + ) { + + } @if (section() === 'events') {
this._state.setOptions({ date: d }); /** Set filter string */ @@ -329,6 +347,15 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { this._state.editUser(); } + public async migrateAll() { + if (this.spaces_need_migration()) { + await this._state.migrateSpaces(); + } + if (this.users_need_migration()) { + await this._state.migrateUsers(); + } + } + public async newReservation() { const { date } = await nextValueFrom(this.options); this._state.editReservation(undefined, { diff --git a/apps/concierge/src/app/staff/emergency-contacts.component.ts b/apps/concierge/src/app/staff/emergency-contacts.component.ts index e022728d83..e8c9a6d77d 100644 --- a/apps/concierge/src/app/staff/emergency-contacts.component.ts +++ b/apps/concierge/src/app/staff/emergency-contacts.component.ts @@ -1,6 +1,6 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { CommonModule } from '@angular/common'; -import { Component, inject, OnInit } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatDialog } from '@angular/material/dialog'; @@ -42,6 +42,22 @@ export { EmergencyContact } from './emergency-contacts.service'; {{ 'APP.CONCIERGE.CONTACTS_HEADER' | translate }}
+ @if (needs_migration()) { + + } { - const needs_migration = await this._contacts_service.needsMigration(); - if (needs_migration) { - const result = await openConfirmModal( - { - title: 'Migrate Emergency Contacts', - content: - 'Emergency contacts data from the old system was found. Would you like to migrate it to the new system?', - icon: { content: 'sync' }, - }, - this._dialog, - ); - if (result.reason === 'done') { - result.loading('Migrating contacts...'); - await this._contacts_service.migrateFromMetadata(); - result.close(); - } else { - result.close(); - } + public async migrateContacts(): Promise { + const result = await openConfirmModal( + { + title: 'Migrate Emergency Contacts', + content: + 'This will migrate emergency contacts data from the legacy metadata system to the new Assets API. The original data will be preserved.', + icon: { content: 'sync' }, + }, + this._dialog, + ); + if (result.reason === 'done') { + result.loading('Migrating contacts...'); + await this._contacts_service.migrateFromMetadata(); + result.close(); + } else { + result.close(); } } diff --git a/apps/concierge/src/app/staff/emergency-contacts.service.ts b/apps/concierge/src/app/staff/emergency-contacts.service.ts index 8691f8d880..6e5424a1e6 100644 --- a/apps/concierge/src/app/staff/emergency-contacts.service.ts +++ b/apps/concierge/src/app/staff/emergency-contacts.service.ts @@ -1,4 +1,4 @@ -import { inject, Injectable } from '@angular/core'; +import { inject, Injectable, signal } from '@angular/core'; import { deleteAsset, queryAssetCategories, @@ -54,6 +54,13 @@ export class EmergencyContactsService { private _change = new BehaviorSubject(Date.now()); + /** Migration status signals */ + private _using_assets_api = signal(false); + private _needs_migration = signal(false); + + public readonly using_assets_api = this._using_assets_api.asReadonly(); + public readonly needs_migration = this._needs_migration.asReadonly(); + /** Observable for the emergency contacts category */ public readonly category$ = combineLatest([ this._org.active_building, @@ -98,8 +105,26 @@ export class EmergencyContactsService { shareReplay(1), ); + /** Legacy metadata fallback - used for migration and dual-source loading */ + public readonly legacyMetadata$ = this._org.active_building.pipe( + filter((bld) => !!bld), + switchMap((bld) => + showMetadata(bld.id, 'emergency_contacts').pipe( + catchError(() => of({ details: { contacts: [], roles: [] } })), + ), + ), + map( + ({ details }) => + (details as EmergencyContactData) || { + contacts: [], + roles: [], + }, + ), + shareReplay(1), + ); + /** Observable for emergency contacts from Assets API */ - public readonly contacts$ = combineLatest([ + private readonly _assets_contacts$ = combineLatest([ this._org.active_building, this.assetType$, this._change, @@ -107,7 +132,11 @@ export class EmergencyContactsService { filter(([bld]) => !!bld), switchMap(([bld, assetType]) => { if (!assetType) return of([] as EmergencyContact[]); - return queryAssets({ zone_id: bld.id, type_id: assetType.id, limit: 200 }).pipe( + return queryAssets({ + zone_id: bld.id, + type_id: assetType.id, + limit: 200, + }).pipe( catchError(() => of([] as Asset[])), map((assets) => assets @@ -119,8 +148,36 @@ export class EmergencyContactsService { shareReplay(1), ); - /** Observable for roles (stored in category description as JSON) */ - public readonly roles$ = this.category$.pipe( + /** Combined contacts with dual-source loading (Assets API first, metadata fallback) */ + public readonly contacts$ = combineLatest([ + this._assets_contacts$, + this.legacyMetadata$, + ]).pipe( + map(([assets_contacts, legacy_data]) => { + // If we have asset-based contacts, use those exclusively + if (assets_contacts.length > 0) { + this._using_assets_api.set(true); + // Check if legacy data exists for migration button + const has_legacy = + legacy_data?.contacts?.length > 0 && + !(legacy_data as any).migrated; + this._needs_migration.set(has_legacy); + return assets_contacts; + } + // Fallback to legacy metadata contacts + this._using_assets_api.set(false); + const legacy_contacts = legacy_data?.contacts || []; + // If metadata has contacts but not migrated, show migration button + this._needs_migration.set( + legacy_contacts.length > 0 && !(legacy_data as any).migrated, + ); + return legacy_contacts; + }), + shareReplay(1), + ); + + /** Observable for roles from Assets API (stored in category description as JSON) */ + private readonly _assets_roles$ = this.category$.pipe( map((category) => { if (!category?.description) return []; try { @@ -133,33 +190,31 @@ export class EmergencyContactsService { shareReplay(1), ); - /** Combined data observable matching the old metadata format */ - public readonly data$ = combineLatest([this.contacts$, this.roles$]).pipe( - map(([contacts, roles]) => ({ contacts, roles })), + /** Combined roles with dual-source loading (Assets API first, metadata fallback) */ + public readonly roles$ = combineLatest([ + this._assets_roles$, + this.legacyMetadata$, + ]).pipe( + map(([assets_roles, legacy_data]) => { + // If we have asset-based roles, use those + if (assets_roles.length > 0) { + return assets_roles; + } + // Fallback to legacy metadata roles + return legacy_data?.roles || []; + }), shareReplay(1), ); - /** Legacy metadata fallback - used for migration */ - private readonly legacyMetadata$ = this._org.active_building.pipe( - filter((bld) => !!bld), - switchMap((bld) => - showMetadata(bld.id, 'emergency_contacts').pipe( - catchError(() => of({ details: { contacts: [], roles: [] } })), - ), - ), - map( - ({ details }) => - (details as EmergencyContactData) || { - contacts: [], - roles: [], - }, - ), + /** Combined data observable matching the old metadata format */ + public readonly data$ = combineLatest([this.contacts$, this.roles$]).pipe( + map(([contacts, roles]) => ({ contacts, roles })), shareReplay(1), ); constructor() { - // Initialize category and asset type on first load if needed - this.ensureCategoryAndTypeExist(); + // Subscribe to contacts$ to initialize migration status + this.contacts$.subscribe(); } /** Ensure the hidden category exists, create if not */ @@ -295,13 +350,17 @@ export class EmergencyContactsService { await firstValueFrom(saveAsset(asset)); } - // Clear old metadata after successful migration + // Mark metadata as migrated (non-destructive - keep original data) await updateMetadata(bld.id, { name: 'emergency_contacts', description: 'Emergency Contacts (migrated to Assets)', - details: { contacts: [], roles: [], migrated: true }, + details: { ...legacy_data, migrated: true }, }).toPromise(); + // Update migration status + this._using_assets_api.set(true); + this._needs_migration.set(false); + this._change.next(Date.now()); notifySuccess( i18n('APP.CONCIERGE.CONTACTS_MIGRATION_SUCCESS') || diff --git a/apps/stagehand/src/app/alerts.component.ts b/apps/stagehand/src/app/alerts.component.ts index f90adf82ef..15f17587a5 100644 --- a/apps/stagehand/src/app/alerts.component.ts +++ b/apps/stagehand/src/app/alerts.component.ts @@ -460,7 +460,7 @@ export class AlertsComponent extends AsyncHandler implements OnInit { // 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, + building: region ? building || 'all' : undefined, }; this._router.navigate([], { @@ -531,13 +531,12 @@ export class AlertsComponent extends AsyncHandler implements OnInit { // 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, - ); + 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 || ''); + const building_id = + building_param === 'all' ? '' : building_param || ''; // Set via dashboards service to prevent constructor overwrite this._dashboards.setRegionFromParams(region.id, building_id); diff --git a/apps/stagehand/src/app/dashboards/dashboards.service.ts b/apps/stagehand/src/app/dashboards/dashboards.service.ts index ec56cdae2c..45ebc92919 100644 --- a/apps/stagehand/src/app/dashboards/dashboards.service.ts +++ b/apps/stagehand/src/app/dashboards/dashboards.service.ts @@ -209,7 +209,8 @@ export class DashboardsService extends AsyncHandler { // Parse query params from hash (for hash routing) or search const hash = location.hash; const queryIndex = hash.indexOf('?'); - const queryString = queryIndex >= 0 ? hash.substring(queryIndex + 1) : ''; + const queryString = + queryIndex >= 0 ? hash.substring(queryIndex + 1) : ''; const params = new URLSearchParams(queryString); const region_param = params.get('region'); @@ -220,7 +221,9 @@ export class DashboardsService extends AsyncHandler { // '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.building_id.set( + building_param === 'all' ? '' : building_param, + ); } } } @@ -478,7 +481,10 @@ export class DashboardsService extends AsyncHandler { /** Send push notifications for new alerts that haven't been notified yet */ private _sendPushNotifications(alerts: Alert[]): void { - log('ALERTS', `_sendPushNotifications called with ${alerts.length} alerts`); + log( + 'ALERTS', + `_sendPushNotifications called with ${alerts.length} alerts`, + ); for (const alert of alerts) { // Skip if already notified if (this._notified_alert_ids.has(alert.id)) { @@ -486,7 +492,10 @@ export class DashboardsService extends AsyncHandler { continue; } - log('ALERTS', `Sending notification for new alert: ${alert.id}, severity: ${alert.severity}`); + log( + 'ALERTS', + `Sending notification for new alert: ${alert.id}, severity: ${alert.severity}`, + ); // Mark as notified before sending to prevent duplicates this._notified_alert_ids.add(alert.id); diff --git a/apps/stagehand/src/app/push-notification.service.ts b/apps/stagehand/src/app/push-notification.service.ts index 3fa4ef6b00..971e04930a 100644 --- a/apps/stagehand/src/app/push-notification.service.ts +++ b/apps/stagehand/src/app/push-notification.service.ts @@ -1,7 +1,7 @@ import { Injectable, signal } from '@angular/core'; import { - log, PushNotificationService as BasePushNotificationService, + log, settingSignal, } from '@placeos/common'; @@ -35,7 +35,9 @@ const DEFAULT_CONFIG: AlertNotificationConfig = { }) export class AlertNotificationService extends BasePushNotificationService { /** User's notification preferences per severity */ - public readonly config = signal(this._loadConfig()); + public readonly config = signal( + this._loadConfig(), + ); /** Default config from settings (can be overridden by zone metadata) */ private _default_config = settingSignal>( diff --git a/libs/bookings/src/index.ts b/libs/bookings/src/index.ts index 02de6fdd15..7178c8af73 100644 --- a/libs/bookings/src/index.ts +++ b/libs/bookings/src/index.ts @@ -14,7 +14,8 @@ export * from './lib/locker.class'; export * from './lib/parking-select-modal/parking-select-modal.component'; export * from './lib/parking-space-list-field.component'; export * from './lib/parking.service'; +export * from './lib/resource-assets.service'; +export * from './lib/recurring-clash-modal.component'; export * from './lib/visitor-invite-form.component'; export * from './lib/visitor-invite-success.component'; -export * from './lib/recurring-clash-modal.component'; diff --git a/libs/bookings/src/lib/booking-form.service.ts b/libs/bookings/src/lib/booking-form.service.ts index ca41b8a87a..a2f9f8f0fc 100644 --- a/libs/bookings/src/lib/booking-form.service.ts +++ b/libs/bookings/src/lib/booking-form.service.ts @@ -2,6 +2,7 @@ import { inject, Injectable, signal } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Event, NavigationEnd, Router } from '@angular/router'; import { + Asset, AsyncHandler, Booking, BookingRuleset, @@ -69,14 +70,66 @@ import { queryBookings, saveBooking, } from './bookings.fn'; -import { openRecurringClashModal } from './recurring-clash-modal.component'; import { DeskQuestionsModalComponent } from './desk-questions-modal.component'; +import { openRecurringClashModal } from './recurring-clash-modal.component'; +import { ResourceAssetsService, ResourceType } from './resource-assets.service'; import { AssetStateService } from 'libs/assets/src/lib/asset-state.service'; import { validateAssetRequestsForResource } from 'libs/assets/src/lib/assets.fn'; import { openConfirmModal } from 'libs/components/src/lib/confirm-modal.component'; import { PaymentsService } from 'libs/payments/src/lib/payments.service'; +/** Desk to Asset mapping for booking form */ +const DESK_ASSET_MAPPING = { + assetToResource: (asset: Asset, zone_id?: string): BookingAsset => { + const other_data = asset.other_data as Record; + return { + id: asset.id, + map_id: other_data?.map_id || asset.id, + name: asset.identifier || '', + bookable: asset.bookable ?? false, + assigned_to: asset.assigned_to || '', + groups: other_data?.groups || [], + features: asset.features || [], + }; + }, + resourceToAsset: () => ({}) as Partial, +}; + +/** Parking space to Asset mapping for booking form */ +const PARKING_SPACE_ASSET_MAPPING = { + assetToResource: (asset: Asset, zone_id?: string): BookingAsset => { + const other_data = asset.other_data as Record; + return { + id: asset.id, + map_id: other_data?.map_id || asset.id, + name: asset.identifier || '', + bookable: true, + assigned_to: asset.assigned_to || '', + features: [], + }; + }, + resourceToAsset: () => ({}) as Partial, +}; + +/** Map metadata type to resource type */ +const METADATA_TO_RESOURCE_TYPE: Record = { + desks: 'desks', + 'parking-spaces': 'parking-spaces', +}; + +/** Map metadata type to asset mapping */ +const METADATA_TO_ASSET_MAPPING: Record = { + desks: DESK_ASSET_MAPPING, + 'parking-spaces': PARKING_SPACE_ASSET_MAPPING, +}; + +/** Legacy metadata mapping */ +const legacyResourceMapFn = (item: any, zone_id: string): BookingAsset => ({ + ...item, + id: item.id || item.map_id, +}); + export type BookingFlowView = 'form' | 'map' | 'confirm' | 'success'; const BOOKING_TYPES = ['desk', 'parking', 'locker', 'catering']; @@ -125,6 +178,7 @@ export class BookingFormService extends AsyncHandler { private _dialog = inject(MatDialog); private _payments = inject(PaymentsService); private _assets = inject(AssetStateService); + private _resourceAssets = inject(ResourceAssetsService); private _options = new BehaviorSubject({ type: 'desk', }); @@ -956,9 +1010,15 @@ export class BookingFormService extends AsyncHandler { // Check setting for allow_recurring_instance_clashes const allow_clashes = - this._settings.get(`app.${type}s.allow_recurring_instance_clashes`) ?? - this._settings.get(`app.${type}.allow_recurring_instance_clashes`) ?? - this._settings.get('app.bookings.allow_recurring_instance_clashes') ?? + this._settings.get( + `app.${type}s.allow_recurring_instance_clashes`, + ) ?? + this._settings.get( + `app.${type}.allow_recurring_instance_clashes`, + ) ?? + this._settings.get( + 'app.bookings.allow_recurring_instance_clashes', + ) ?? true; if (!allow_clashes) { @@ -980,8 +1040,97 @@ export class BookingFormService extends AsyncHandler { return true; } - public loadResourceList(type: string) { + public loadResourceList(type: string): Observable { const use_region = this._settings.get('app.use_region'); + const resource_type = METADATA_TO_RESOURCE_TYPE[type]; + const asset_mapping = METADATA_TO_ASSET_MAPPING[type]; + + // If we have asset mapping, try dual-source loading + if (resource_type && asset_mapping) { + return this._loadResourceListWithFallback(type, use_region); + } + + // Fallback to legacy metadata loading for unsupported types + return this._loadResourceListLegacy(type, use_region); + } + + private _loadResourceListWithFallback( + type: string, + use_region: boolean, + ): Observable { + const resource_type = METADATA_TO_RESOURCE_TYPE[type]; + const asset_mapping = METADATA_TO_ASSET_MAPPING[type]; + + if (use_region) { + const region_id = this._org.building.parent_id; + const buildings = this._org.buildings.filter( + (_) => _.parent_id === region_id, + ); + // Load from all buildings in region + return forkJoin( + buildings.map((bld) => + this._loadBuildingResources( + bld.id, + type, + resource_type, + asset_mapping, + ), + ), + ).pipe( + map((results) => flatten(results)), + catchError(() => + this._loadResourceListLegacy(type, use_region), + ), + ); + } + + // Load from current building + return this._loadBuildingResources( + this._org.building.id, + type, + resource_type, + asset_mapping, + ).pipe( + catchError(() => this._loadResourceListLegacy(type, use_region)), + ); + } + + private _loadBuildingResources( + building_id: string, + metadata_type: string, + resource_type: ResourceType, + asset_mapping: any, + ): Observable { + const levels = this._org.levelsForBuilding({ id: building_id } as any); + if (!levels.length) return of([]); + + return forkJoin( + levels.map((lvl) => + this._resourceAssets + .loadWithFallback$( + resource_type, + metadata_type, + lvl.id, + asset_mapping, + legacyResourceMapFn, + ) + .pipe( + map((resources) => + resources.map((r) => ({ + ...r, + zone: lvl, + })), + ), + catchError(() => of([] as BookingAsset[])), + ), + ), + ).pipe(map((results) => flatten(results))); + } + + private _loadResourceListLegacy( + type: string, + use_region: boolean, + ): Observable { const map_metadata = (_) => (_?.metadata[type]?.details instanceof Array ? _.metadata[type].details @@ -991,25 +1140,28 @@ export class BookingFormService extends AsyncHandler { id: d.id || d.map_id, zone: _.zone, })); - const id = use_region - ? this._org.building.parent_id - : this._org.building.id; + if (use_region) { - const id = this._org.building.parent_id; + const region_id = this._org.building.parent_id; const buildings = this._org.buildings.filter( - (_) => _.parent_id === id, + (_) => _.parent_id === region_id, ); return forkJoin( buildings.map((_) => listChildMetadata(_.id, { name: type }).pipe( map((data) => flatten(data.map(map_metadata))), + catchError(() => of([])), ), ), ).pipe(map((_) => flatten(_))); } - return listChildMetadata(id, { + + return listChildMetadata(this._org.building.id, { name: type, - }).pipe(map((data) => flatten(data.map(map_metadata)))); + }).pipe( + map((data) => flatten(data.map(map_metadata))), + catchError(() => of([])), + ); } private async _getNearbyResources( diff --git a/libs/bookings/src/lib/booking.utilities.ts b/libs/bookings/src/lib/booking.utilities.ts index 668e005eb8..54bfbec629 100644 --- a/libs/bookings/src/lib/booking.utilities.ts +++ b/libs/bookings/src/lib/booking.utilities.ts @@ -1,5 +1,7 @@ +import { inject } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { + Asset, Booking, CalendarEvent, current_user, @@ -29,6 +31,101 @@ import { switchMap, } from 'rxjs/operators'; import { Locker, LockerBank } from './locker.class'; +import { ResourceAssetsService } from './resource-assets.service'; + +/** LockerBank to Asset mapping */ +export const LOCKER_BANK_ASSET_MAPPING = { + assetToResource: (asset: Asset, zone_id?: string): LockerBank => { + const other_data = asset.other_data as Record; + const bank: LockerBank = { + id: asset.id, + map_id: other_data?.map_id || asset.id, + level_id: other_data?.level_id || '', + name: asset.identifier || '', + height: other_data?.height || 0, + zones: asset.zones || [], + tags: other_data?.tags || [], + }; + // Store client_id for matching lockers that reference old bank IDs + (bank as any).client_id = other_data?.client_id || ''; + return bank; + }, + resourceToAsset: ( + bank: LockerBank, + asset_type_id: string, + zone_id: string, + zones: string[], + ): Partial => ({ + id: bank.id?.startsWith('temp-') ? undefined : bank.id, + asset_type_id, + identifier: bank.name, + zone_id, + zones: bank.zones?.length ? bank.zones : zones, + other_data: { + client_id: bank.id, // Save original ID for locker matching + map_id: bank.map_id || bank.id, + level_id: bank.level_id || '', + height: bank.height || 0, + tags: bank.tags || [], + } as Record, + }), +}; + +/** Locker to Asset mapping */ +export const LOCKER_ASSET_MAPPING = { + assetToResource: (asset: Asset, zone_id?: string): Locker => { + const other_data = asset.other_data as Record; + return { + id: asset.id, + bank_id: other_data?.bank_id || '', + map_id: other_data?.map_id || asset.id, + name: asset.identifier || '', + assigned_to: asset.assigned_to || '', + available: other_data?.available ?? true, + accessible: asset.accessible ?? false, + bookable: asset.bookable ?? false, + position: other_data?.position || [0, 0], + size: other_data?.size || [1, 1], + features: asset.features || [], + }; + }, + resourceToAsset: ( + locker: Locker, + asset_type_id: string, + zone_id: string, + zones: string[], + ): Partial => ({ + id: locker.id?.startsWith('temp-') ? undefined : locker.id, + asset_type_id, + identifier: locker.name, + assigned_to: locker.assigned_to || '', + bookable: locker.bookable ?? false, + accessible: locker.accessible ?? false, + features: locker.features || [], + zone_id, + zones, + other_data: { + bank_id: locker.bank_id || '', + map_id: locker.map_id || locker.id, + position: locker.position || [0, 0], + size: locker.size || [1, 1], + available: locker.available ?? true, + } as Record, + }), +}; + +/** Legacy metadata to LockerBank mapping */ +export const legacyLockerBankMapFn = ( + item: any, + zone_id: string, +): LockerBank => ({ + ...item, +}); + +/** Legacy metadata to Locker mapping */ +export const legacyLockerMapFn = (item: any, zone_id: string): Locker => ({ + ...item, +}); function setBookingAsset(form: FormGroup, resource: any) { if (!resource) return form.patchValue({ asset_id: undefined }); @@ -262,26 +359,61 @@ export function loadLockerBanks( org: OrganisationService, obs: Observable, useRegion: () => boolean, + resourceAssets?: ResourceAssetsService, ): Observable { return obs.pipe( filter(([bld]) => !!bld), - switchMap(([bld]) => - useRegion() - ? forkJoin( - org.buildingsForRegion().map((building) => - showMetadata(building.id, 'locker_banks').pipe( - catchError(() => of(new PlaceMetadata())), - map((_) => - _.details instanceof Array ? _.details : [], - ), - ), - ), - ).pipe(map((_: LockerBank[][]) => flatten(_))) - : showMetadata(bld.id, 'locker_banks').pipe( - catchError(() => of(new PlaceMetadata())), - map((_) => (_.details instanceof Array ? _.details : [])), - ), - ), + switchMap(([bld]) => { + if (useRegion()) { + const buildings = org.buildingsForRegion(); + if (resourceAssets) { + return resourceAssets.loadResourcesFromZones$( + 'locker_banks', + buildings.map((b) => b.id), + LOCKER_BANK_ASSET_MAPPING, + ).pipe( + switchMap((assets) => { + if (assets.length > 0) return of(assets); + // Fallback to legacy metadata + return forkJoin( + buildings.map((building) => + showMetadata(building.id, 'locker_banks').pipe( + catchError(() => of(new PlaceMetadata())), + map((_) => + _.details instanceof Array ? _.details : [], + ), + ), + ), + ).pipe(map((_: LockerBank[][]) => flatten(_))); + }), + ); + } + return forkJoin( + buildings.map((building) => + showMetadata(building.id, 'locker_banks').pipe( + catchError(() => of(new PlaceMetadata())), + map((_) => + _.details instanceof Array ? _.details : [], + ), + ), + ), + ).pipe(map((_: LockerBank[][]) => flatten(_))); + } else { + if (resourceAssets) { + return resourceAssets.loadWithFallback$( + 'locker_banks', + 'locker_banks', + bld.id, + LOCKER_BANK_ASSET_MAPPING, + legacyLockerBankMapFn, + ); + } + return showMetadata(bld.id, 'locker_banks').pipe( + catchError(() => of(new PlaceMetadata())), + map((_) => (_.details instanceof Array ? _.details : [])), + ); + } + }), shareReplay(1), ); } @@ -291,47 +423,94 @@ export function loadLockers( obs: Observable, banks$: Observable, useRegion: () => boolean, + resourceAssets?: ResourceAssetsService, ): Observable { return obs.pipe( filter(([bld]) => !!bld), - switchMap(([bld]) => - combineLatest([ - useRegion() - ? forkJoin( - org.buildingsForRegion().map((building) => - showMetadata(building.id, 'lockers').pipe( - catchError(() => of(new PlaceMetadata())), - map((_) => - _.details instanceof Array - ? _.details - : [], - ), - ), - ), - ).pipe(map((_: Locker[][]) => flatten(_))) - : showMetadata(bld.id, 'lockers').pipe( - catchError(() => of(new PlaceMetadata())), - map((_) => - _.details instanceof Array ? _.details : [], - ), - ), - banks$, - ]), - ), + switchMap(([bld]) => { + let lockers$: Observable; + if (useRegion()) { + const buildings = org.buildingsForRegion(); + if (resourceAssets) { + lockers$ = resourceAssets.loadResourcesFromZones$( + 'lockers', + buildings.map((b) => b.id), + LOCKER_ASSET_MAPPING, + ).pipe( + switchMap((assets) => { + if (assets.length > 0) return of(assets); + // Fallback to legacy metadata + return forkJoin( + buildings.map((building) => + showMetadata(building.id, 'lockers').pipe( + catchError(() => of(new PlaceMetadata())), + map((_) => + _.details instanceof Array + ? _.details + : [], + ), + ), + ), + ).pipe(map((_: Locker[][]) => flatten(_))); + }), + ); + } else { + lockers$ = forkJoin( + buildings.map((building) => + showMetadata(building.id, 'lockers').pipe( + catchError(() => of(new PlaceMetadata())), + map((_) => + _.details instanceof Array + ? _.details + : [], + ), + ), + ), + ).pipe(map((_: Locker[][]) => flatten(_))); + } + } else { + if (resourceAssets) { + lockers$ = resourceAssets.loadWithFallback$( + 'lockers', + 'lockers', + bld.id, + LOCKER_ASSET_MAPPING, + legacyLockerMapFn, + ); + } else { + lockers$ = showMetadata(bld.id, 'lockers').pipe( + catchError(() => of(new PlaceMetadata())), + map((_) => + _.details instanceof Array ? _.details : [], + ), + ); + } + } + return combineLatest([lockers$, banks$]); + }), map(([lockers, banks]: any) => { const locker_list = lockers; + // Helper to find bank by id or client_id (for migrated data) + const findBank = (bank_id: string) => + banks.find( + (b: any) => b.id === bank_id || b.client_id === bank_id, + ); for (const bank of banks) { bank.lockers = lockers - .filter((_) => _.bank_id === bank.id) - .map((_) => ({ ..._ })); + .filter( + (_: any) => + _.bank_id === bank.id || + _.bank_id === (bank as any).client_id, + ) + .map((_: any) => ({ ..._ })); } for (const locker of locker_list) { - const bank = banks.find((b) => b.id === locker.bank_id); + const bank = findBank(locker.bank_id); locker.bank = bank; locker.tags = bank?.tags || []; locker.zone = org.levelWithID(bank?.zones || []); } - return lockers.filter((_) => _.bank); + return lockers.filter((_: any) => _.bank); }), shareReplay(1), ); diff --git a/libs/bookings/src/lib/desk-select-modal/desk-details.component.ts b/libs/bookings/src/lib/desk-select-modal/desk-details.component.ts index 77269c0444..a9bb9598a7 100644 --- a/libs/bookings/src/lib/desk-select-modal/desk-details.component.ts +++ b/libs/bookings/src/lib/desk-select-modal/desk-details.component.ts @@ -74,7 +74,7 @@ import { BookingAsset } from '../booking-form.service'; >

- {{ desk().display_name || desk().name || desk().id }} + {{ desk().display_name || desk().name || desk().client_id || desk().id }}

@@ -89,7 +89,7 @@ import { BookingAsset } from '../booking-form.service'; desk

{{ - desk().display_name || desk().name || desk().id + desk().display_name || desk().name || desk().client_id || desk().id }}

diff --git a/libs/bookings/src/lib/desk-select-modal/desk-list.component.ts b/libs/bookings/src/lib/desk-select-modal/desk-list.component.ts index 6ec6e0abb3..a122564268 100644 --- a/libs/bookings/src/lib/desk-select-modal/desk-list.component.ts +++ b/libs/bookings/src/lib/desk-select-modal/desk-list.component.ts @@ -70,7 +70,7 @@ import { BookingAsset, BookingFormService } from '../booking-form.service';
- {{ desk.name || desk.id || 'Desk' }} + {{ desk.name || desk.client_id || desk.id || 'Desk' }}
{ + const other_data = asset.other_data as Record; + return { + id: asset.id, + map_id: other_data?.map_id || asset.id, + name: asset.identifier || '', + notes: other_data?.notes || asset.notes || '', + assigned_to: asset.assigned_to || '', + }; + }, + resourceToAsset: ( + space: ParkingSpace, + asset_type_id: string, + zone_id: string, + zones: string[], + ): Partial => ({ + id: space.id?.startsWith('temp-') ? undefined : space.id, + asset_type_id, + identifier: space.name, + assigned_to: space.assigned_to || '', + notes: space.notes || '', + zone_id, + zones, + other_data: { + map_id: space.map_id || space.id, + notes: space.notes || '', + } as Record, + }), +}; + +/** ParkingUser to Asset mapping */ +const PARKING_USER_ASSET_MAPPING = { + assetToResource: (asset: Asset, zone_id?: string): ParkingUser => { + const other_data = asset.other_data as Record; + return { + id: asset.id, + name: asset.identifier || '', + email: other_data?.email || '', + car_model: other_data?.car_model || '', + car_colour: other_data?.car_colour || '', + plate_number: other_data?.plate_number || '', + phone: other_data?.phone || '', + notes: other_data?.notes || asset.notes || '', + deny: other_data?.deny ?? false, + }; + }, + resourceToAsset: ( + user: ParkingUser, + asset_type_id: string, + zone_id: string, + zones: string[], + ): Partial => ({ + id: user.id?.startsWith('temp-') ? undefined : user.id, + asset_type_id, + identifier: user.name, + notes: user.notes || '', + zone_id, + zones, + other_data: { + email: user.email || '', + car_model: user.car_model || '', + car_colour: user.car_colour || '', + plate_number: user.plate_number || '', + phone: user.phone || '', + notes: user.notes || '', + deny: user.deny ?? false, + } as Record, + }), +}; + +/** Legacy metadata to ParkingSpace mapping */ +const legacySpaceMapFn = (item: any, zone_id: string): ParkingSpace => ({ + ...item, +}); + +/** Legacy metadata to ParkingUser mapping */ +const legacyUserMapFn = (item: any, zone_id: string): ParkingUser => ({ + ...item, +}); + @Injectable({ providedIn: 'root', }) export class ParkingService extends AsyncHandler { private _org = inject(OrganisationService); private _settings = inject(SettingsService); + private _resourceAssets = inject(ResourceAssetsService); private _loading = new BehaviorSubject([]); @@ -82,18 +166,23 @@ export class ParkingService extends AsyncHandler { this._loading.next([...this._loading.getValue(), 'spaces']); return forkJoin( levels.map((lvl) => - showMetadata(lvl.id, 'parking-spaces').pipe( - map( - (d) => - (d.details instanceof Array - ? d.details - : [] - ).map((s) => ({ + this._resourceAssets + .loadWithFallback$( + 'parking-spaces', + 'parking-spaces', + lvl.id, + PARKING_SPACE_ASSET_MAPPING, + legacySpaceMapFn, + ) + .pipe( + map((spaces) => + spaces.map((s) => ({ ...s, zone_id: lvl.id, - })) as ParkingSpace[], + })), + ), + catchError(() => of([] as ParkingSpace[])), ), - ), ), ); }), @@ -106,19 +195,21 @@ export class ParkingService extends AsyncHandler { shareReplay(1), ); - /** List of parking spaces for the current building/level */ + /** List of parking users for the current building */ public users = combineLatest([this._org.active_building]).pipe( filter(([bld]) => !!bld?.id), switchMap(([bld]) => { this._loading.next([...this._loading.getValue(), 'users']); - return showMetadata(bld.id, 'parking-users'); + return this._resourceAssets + .loadWithFallback$( + 'parking-users', + 'parking-users', + bld.id, + PARKING_USER_ASSET_MAPPING, + legacyUserMapFn, + ) + .pipe(catchError(() => of([] as ParkingUser[]))); }), - map( - (metadata) => - (metadata.details instanceof Array - ? metadata.details - : []) as ParkingUser[], - ), tap(() => this._loading.next( this._loading.getValue().filter((_) => _ !== 'users'), diff --git a/libs/bookings/src/lib/resource-assets.service.ts b/libs/bookings/src/lib/resource-assets.service.ts new file mode 100644 index 0000000000..713243b5d5 --- /dev/null +++ b/libs/bookings/src/lib/resource-assets.service.ts @@ -0,0 +1,629 @@ +import { inject, Injectable } from '@angular/core'; +import { + deleteAsset, + queryAssetCategories, + queryAssetGroups, + queryAssets, + saveAsset, + saveAssetCategory, + saveAssetGroup, +} from '@placeos/assets'; +import { + Asset, + AssetCategory, + AssetGroup, + i18n, + notifyError, + notifySuccess, + OrganisationService, + randomString, + unique, +} from '@placeos/common'; +import { + cleanObject, + PlaceMetadata, + showMetadata, + updateMetadata, +} from '@placeos/ts-client'; +import { firstValueFrom, forkJoin, Observable, of } from 'rxjs'; +import { catchError, map, shareReplay, switchMap } from 'rxjs/operators'; + +/** Category names for each resource type */ +export const RESOURCE_CATEGORY_NAMES = { + desks: '_DESKS_', + 'parking-spaces': '_PARKING_SPACES_', + 'parking-users': '_PARKING_USERS_', + lockers: '_LOCKERS_', + locker_banks: '_LOCKER_BANKS_', +} as const; + +export type ResourceType = keyof typeof RESOURCE_CATEGORY_NAMES; + +interface ResourceMapping { + /** Convert asset to resource */ + assetToResource: (asset: Asset, zone_id?: string) => T; + /** Convert resource to asset partial */ + resourceToAsset: ( + resource: T, + asset_type_id: string, + zone_id: string, + zones: string[], + ) => Partial; +} + +@Injectable({ + providedIn: 'root', +}) +export class ResourceAssetsService { + private _org = inject(OrganisationService); + + /** Cache for category lookups by zone */ + private _category_cache = new Map< + string, + Observable + >(); + /** Cache for asset type lookups by zone and category */ + private _type_cache = new Map>(); + + /** + * Get the hidden category for a resource type in a zone + */ + public getCategory$( + resource_type: ResourceType, + zone_id: string, + ): Observable { + const cache_key = `${resource_type}:${zone_id}`; + if (!this._category_cache.has(cache_key)) { + const category_name = RESOURCE_CATEGORY_NAMES[resource_type]; + const obs = queryAssetCategories({ zone_id }).pipe( + catchError(() => of([] as AssetCategory[])), + map( + (categories) => + categories.find((c) => c.name === category_name) || + null, + ), + shareReplay(1), + ); + this._category_cache.set(cache_key, obs); + } + return this._category_cache.get(cache_key); + } + + /** + * Get the asset type/group for a resource type in a zone + */ + public getAssetType$( + resource_type: ResourceType, + zone_id: string, + ): Observable { + const cache_key = `${resource_type}:${zone_id}`; + if (!this._type_cache.has(cache_key)) { + const category_name = RESOURCE_CATEGORY_NAMES[resource_type]; + const obs = this.getCategory$(resource_type, zone_id).pipe( + switchMap((category) => { + if (!category) return of(null as AssetGroup | null); + return queryAssetGroups({ + zone_id, + q: category.name, + }).pipe( + catchError(() => of([] as AssetGroup[])), + map( + (groups) => + groups.find( + (g) => + g.name === category_name && + g.category_id === category.id, + ) || null, + ), + ); + }), + shareReplay(1), + ); + this._type_cache.set(cache_key, obs); + } + return this._type_cache.get(cache_key); + } + + /** + * Clear cached category/type lookups for a zone + */ + public clearCache(resource_type: ResourceType, zone_id: string): void { + const cache_key = `${resource_type}:${zone_id}`; + this._category_cache.delete(cache_key); + this._type_cache.delete(cache_key); + } + + /** + * Ensure the category exists for a resource type, create if not + */ + public async ensureCategoryExists( + resource_type: ResourceType, + zone_id: string, + ): Promise { + const category_name = RESOURCE_CATEGORY_NAMES[resource_type]; + const categories = await firstValueFrom( + queryAssetCategories({ zone_id }).pipe( + catchError(() => of([] as AssetCategory[])), + ), + ); + + const existing = categories.find((c) => c.name === category_name); + if (existing) return existing; + + try { + const new_category = await firstValueFrom( + saveAssetCategory( + cleanObject( + new AssetCategory({ + name: category_name, + description: JSON.stringify({ + resource_type, + created_at: Date.now(), + }), + hidden: true, + }), + [0, undefined, '', null], + ), + ), + ); + this.clearCache(resource_type, zone_id); + return new_category; + } catch (e) { + console.error(`Failed to create ${resource_type} category:`, e); + return null; + } + } + + /** + * Ensure the asset type exists for a resource type, create if not + */ + public async ensureAssetTypeExists( + resource_type: ResourceType, + zone_id: string, + category: AssetCategory, + ): Promise { + if (!category) return null; + + const category_name = RESOURCE_CATEGORY_NAMES[resource_type]; + const groups = await firstValueFrom( + queryAssetGroups({ zone_id, q: category.name }).pipe( + catchError(() => of([] as AssetGroup[])), + ), + ); + + const existing = groups.find( + (g) => g.name === category_name && g.category_id === category.id, + ); + if (existing) return existing; + + try { + const new_group = await firstValueFrom( + saveAssetGroup({ + name: category_name, + category_id: category.id, + zone_id, + brand: 'PlaceOS', + description: `${resource_type} resources`, + }), + ); + this.clearCache(resource_type, zone_id); + return new_group; + } catch (e) { + console.error(`Failed to create ${resource_type} asset type:`, e); + return null; + } + } + + /** + * Ensure both category and asset type exist + */ + public async ensureCategoryAndTypeExist( + resource_type: ResourceType, + zone_id: string, + ): Promise { + const category = await this.ensureCategoryExists( + resource_type, + zone_id, + ); + if (!category) return null; + return this.ensureAssetTypeExists(resource_type, zone_id, category); + } + + /** + * Load resources from Assets API + */ + public loadResources$( + resource_type: ResourceType, + zone_id: string, + mapping: ResourceMapping, + ): Observable { + return this.getAssetType$(resource_type, zone_id).pipe( + switchMap((asset_type) => { + if (!asset_type) return of([] as T[]); + return queryAssets({ + zone_id, + type_id: asset_type.id, + limit: 2000, + }).pipe( + catchError(() => of([] as Asset[])), + map((assets) => + assets + .filter((a) => a.asset_type_id === asset_type.id) + .map((a) => mapping.assetToResource(a, zone_id)), + ), + ); + }), + ); + } + + /** + * Load resources from multiple zones (for region mode) + */ + public loadResourcesFromZones$( + resource_type: ResourceType, + zone_ids: string[], + mapping: ResourceMapping, + ): Observable { + if (!zone_ids.length) return of([]); + return forkJoin( + zone_ids.map((zone_id) => + this.loadResources$(resource_type, zone_id, mapping).pipe( + catchError(() => of([] as T[])), + ), + ), + ).pipe(map((results) => results.flat())); + } + + /** + * Check if legacy metadata exists and needs migration + */ + public async needsMigration( + metadata_name: string, + zone_id: string, + ): Promise { + try { + const metadata = await firstValueFrom( + showMetadata(zone_id, metadata_name).pipe( + catchError(() => of({ details: null } as PlaceMetadata)), + ), + ); + const details = metadata?.details as any; + if (!details) return false; + // Check if already migrated + if (details.migrated === true) return false; + // Check if there's actual data to migrate + if (Array.isArray(details)) return details.length > 0; + // Handle object-based metadata (e.g., emergency_contacts with { contacts: [], roles: [] }) + if (typeof details === 'object') { + // Check for common array properties that contain the actual data + for (const key of Object.keys(details)) { + if (key === 'migrated') continue; + const value = details[key]; + if (Array.isArray(value) && value.length > 0) { + return true; + } + } + } + return false; + } catch { + return false; + } + } + + /** + * Load legacy metadata resources + */ + public loadLegacyMetadata$( + metadata_name: string, + zone_id: string, + map_fn: (item: any, zone_id: string) => T, + ): Observable { + return showMetadata(zone_id, metadata_name).pipe( + catchError(() => of({ details: [] } as PlaceMetadata)), + map((metadata) => { + const details = metadata?.details; + if (!details) return []; + // Check if migrated + if ((details as any).migrated === true) return []; + if (Array.isArray(details)) { + return details.map((item) => map_fn(item, zone_id)); + } + return []; + }), + ); + } + + /** + * Load resources with fallback to legacy metadata + * Tries Assets API first, falls back to metadata if empty + */ + public loadWithFallback$( + resource_type: ResourceType, + metadata_name: string, + zone_id: string, + asset_mapping: ResourceMapping, + legacy_map_fn: (item: any, zone_id: string) => T, + ): Observable { + return this.loadResources$(resource_type, zone_id, asset_mapping).pipe( + switchMap((assets) => { + if (assets.length > 0) return of(assets); + // Fallback to legacy metadata + return this.loadLegacyMetadata$( + metadata_name, + zone_id, + legacy_map_fn, + ); + }), + ); + } + + /** + * Save a resource to the Assets API + */ + public async saveResource( + resource_type: ResourceType, + resource: T, + zone_id: string, + mapping: ResourceMapping, + resource_id?: string, + ): Promise { + try { + let asset_type = await firstValueFrom( + this.getAssetType$(resource_type, zone_id), + ); + if (!asset_type) { + asset_type = await this.ensureCategoryAndTypeExist( + resource_type, + zone_id, + ); + } + if (!asset_type) { + throw new Error('Failed to create or find asset type'); + } + + const zones = this._buildZones(zone_id); + const asset_data = mapping.resourceToAsset( + resource, + asset_type.id, + zone_id, + zones, + ) as any; + + // Handle existing resource ID + if (resource_id && !resource_id.startsWith('temp-')) { + asset_data.id = resource_id; + } + + const result = await firstValueFrom(saveAsset(asset_data)); + this.clearCache(resource_type, zone_id); + return result; + } catch (e) { + console.error(`Failed to save ${resource_type} resource:`, e); + throw e; + } + } + + /** + * Delete a resource from the Assets API + */ + public async deleteResource(asset_id: string): Promise { + try { + await firstValueFrom(deleteAsset(asset_id)); + return true; + } catch (e) { + console.error('Failed to delete resource:', e); + return false; + } + } + + /** + * Migrate resources from metadata to Assets API + */ + public async migrateFromMetadata( + resource_type: ResourceType, + metadata_name: string, + zone_id: string, + mapping: ResourceMapping, + legacy_map_fn: (item: any, zone_id: string) => T, + ): Promise { + try { + // Load legacy metadata + const metadata = await firstValueFrom( + showMetadata(zone_id, metadata_name).pipe( + catchError(() => of({ details: [] } as PlaceMetadata)), + ), + ); + + const details = metadata?.details; + if (!details || (details as any).migrated === true) { + return true; // Nothing to migrate or already migrated + } + + const items = Array.isArray(details) ? details : []; + if (items.length === 0) { + return true; // Nothing to migrate + } + + // Ensure category and asset type exist + const asset_type = await this.ensureCategoryAndTypeExist( + resource_type, + zone_id, + ); + if (!asset_type) { + throw new Error('Failed to create or find asset type'); + } + + const zones = this._buildZones(zone_id); + + // Migrate each item + for (const item of items) { + const resource = legacy_map_fn(item, zone_id); + const asset_data = mapping.resourceToAsset( + resource, + asset_type.id, + zone_id, + zones, + ) as any; + // Remove id so it creates new assets + delete asset_data.id; + await firstValueFrom(saveAsset(asset_data)); + } + + // Mark metadata as migrated, retaining original data for rollback + await firstValueFrom( + updateMetadata(zone_id, { + name: metadata_name, + description: `${metadata_name} (migrated to Assets API)`, + details: { + migrated: true, + migrated_at: Date.now(), + original_data: items, + }, + }), + ); + + this.clearCache(resource_type, zone_id); + notifySuccess( + i18n('COMMON.MIGRATION_SUCCESS') || + `Successfully migrated ${items.length} ${resource_type}.`, + ); + return true; + } catch (e) { + notifyError( + i18n('COMMON.MIGRATION_ERROR', { error: e }) || + `Failed to migrate ${resource_type}: ${e}`, + ); + return false; + } + } + + /** + * Migrate resources from metadata to Assets API with custom transform + * Similar to migrateFromMetadata but allows custom transformation of items + */ + public async migrateFromMetadataWithTransform( + resource_type: ResourceType, + metadata_name: string, + zone_id: string, + mapping: ResourceMapping, + transform_fn: (item: any, zone_id: string) => T, + ): Promise { + try { + // Load legacy metadata + const metadata = await firstValueFrom( + showMetadata(zone_id, metadata_name).pipe( + catchError(() => of({ details: [] } as PlaceMetadata)), + ), + ); + + const details = metadata?.details; + if (!details || (details as any).migrated === true) { + return true; // Nothing to migrate or already migrated + } + + const items = Array.isArray(details) ? details : []; + if (items.length === 0) { + return true; // Nothing to migrate + } + + // Ensure category and asset type exist + const asset_type = await this.ensureCategoryAndTypeExist( + resource_type, + zone_id, + ); + if (!asset_type) { + throw new Error('Failed to create or find asset type'); + } + + const zones = this._buildZones(zone_id); + + // Migrate each item with custom transform + for (const item of items) { + const resource = transform_fn(item, zone_id); + const asset_data = mapping.resourceToAsset( + resource, + asset_type.id, + zone_id, + zones, + ) as any; + // Remove id so it creates new assets + delete asset_data.id; + await firstValueFrom(saveAsset(asset_data)); + } + + // Mark metadata as migrated, retaining original data for rollback + await firstValueFrom( + updateMetadata(zone_id, { + name: metadata_name, + description: `${metadata_name} (migrated to Assets API)`, + details: { + migrated: true, + migrated_at: Date.now(), + original_data: items, + }, + }), + ); + + this.clearCache(resource_type, zone_id); + notifySuccess( + i18n('COMMON.MIGRATION_SUCCESS') || + `Successfully migrated ${items.length} ${resource_type}.`, + ); + return true; + } catch (e) { + notifyError( + i18n('COMMON.MIGRATION_ERROR', { error: e }) || + `Failed to migrate ${resource_type}: ${e}`, + ); + return false; + } + } + + /** + * Check if using Assets API (migrated) or legacy metadata + */ + public async isUsingAssetsAPI( + resource_type: ResourceType, + zone_id: string, + ): Promise { + const asset_type = await firstValueFrom( + this.getAssetType$(resource_type, zone_id), + ); + if (!asset_type) return false; + + const assets = await firstValueFrom( + queryAssets({ + zone_id, + type_id: asset_type.id, + limit: 1, + }).pipe(catchError(() => of([] as Asset[]))), + ); + return assets.length > 0; + } + + /** + * Generate a temporary ID for new resources + */ + public generateTempId(prefix: string): string { + return `temp-${prefix}-${randomString(8)}`; + } + + /** + * Build zone hierarchy array + */ + private _buildZones(zone_id: string): string[] { + const level = this._org.levelWithID([zone_id]); + const building = level + ? this._org.buildings.find((b) => b.id === level.parent_id) + : this._org.buildings.find((b) => b.id === zone_id); + + return unique( + [ + this._org.organisation?.id, + this._org.region?.id, + building?.id, + level?.id, + ].filter(Boolean), + ); + } +} diff --git a/libs/explore/src/lib/explore-desks.service.ts b/libs/explore/src/lib/explore-desks.service.ts index cfd765bacf..feaad7c0e2 100644 --- a/libs/explore/src/lib/explore-desks.service.ts +++ b/libs/explore/src/lib/explore-desks.service.ts @@ -26,6 +26,7 @@ import { } from 'rxjs/operators'; import { + Asset, AsyncHandler, BookingRuleset, currentUser, @@ -41,6 +42,7 @@ import { StaffUser, } from '@placeos/common'; import { BookingFormService } from 'libs/bookings/src/lib/booking-form.service'; +import { ResourceAssetsService } from 'libs/bookings/src/lib/resource-assets.service'; import { queryBookings } from 'libs/bookings/src/lib/bookings.fn'; import { SetDatetimeModalComponent } from 'libs/explore/src/lib/set-datetime-modal.component'; @@ -49,6 +51,53 @@ import { ExploreDeviceInfoComponent } from './explore-device-info.component'; import { DEFAULT_COLOURS } from './explore-spaces.service'; import { ExploreStateService } from './explore-state.service'; +/** Desk to Asset mapping for explore */ +const DESK_ASSET_MAPPING = { + assetToResource: (asset: Asset, zone_id?: string): Desk => { + const other_data = asset.other_data as Record; + const desk = new Desk({ + id: asset.id, + map_id: other_data?.map_id || asset.id, + name: asset.identifier || '', + bookable: asset.bookable ?? false, + assigned_to: asset.assigned_to || '', + groups: other_data?.groups || [], + features: asset.features || [], + images: other_data?.images || [], + security: other_data?.security || '', + }); + (desk as any).zone_id = zone_id || asset.zone_id; + (desk as any).notes = asset.notes || ''; + return desk; + }, + resourceToAsset: ( + desk: Desk, + asset_type_id: string, + zone_id: string, + zones: string[], + ): Partial => ({ + id: desk.id?.startsWith('temp-') ? undefined : desk.id, + asset_type_id, + identifier: desk.name, + bookable: desk.bookable, + assigned_to: desk.assigned_to || '', + features: desk.features || [], + notes: (desk as any).notes || '', + zone_id, + zones, + other_data: { + map_id: desk.map_id || desk.id, + groups: desk.groups || [], + images: desk.images || [], + security: desk.security || '', + } as Record, + }), +}; + +/** Legacy metadata to Desk mapping */ +const legacyDeskMapFn = (item: any, zone_id: string): Desk => + new Desk({ ...item, zone_id }); + export interface DeskOptions { enable_booking?: boolean; date?: number; @@ -71,6 +120,7 @@ export class ExploreDesksService extends AsyncHandler implements OnDestroy { private _settings = inject(SettingsService); private _bookings = inject(BookingFormService); private _dialog = inject(MatDialog); + private _resourceAssets = inject(ResourceAssetsService); private _in_use = new BehaviorSubject([]); private _options = new BehaviorSubject({}); @@ -97,15 +147,22 @@ export class ExploreDesksService extends AsyncHandler implements OnDestroy { public readonly desk_list = this._state.level.pipe( debounceTime(50), switchMap((lvl) => - showMetadata(lvl.id, 'desks').pipe( - catchError(() => of({ details: [] })), - map((i) => - (i?.details instanceof Array ? i.details : []).map( - (j: Record) => - new Desk({ ...j, zone: lvl as any }), + this._resourceAssets + .loadWithFallback$( + 'desks', + 'desks', + lvl.id, + DESK_ASSET_MAPPING, + (item, zone_id) => new Desk({ ...item, zone_id }), + ) + .pipe( + map((desks) => + desks.map( + (desk) => new Desk({ ...desk, zone: lvl as any }), + ), ), + catchError(() => of([])), ), - ), ), catchError((e) => []), shareReplay(1), diff --git a/libs/explore/src/lib/explore-search.service.ts b/libs/explore/src/lib/explore-search.service.ts index 95c087fc2a..706f7e7809 100644 --- a/libs/explore/src/lib/explore-search.service.ts +++ b/libs/explore/src/lib/explore-search.service.ts @@ -1,4 +1,5 @@ import { Injectable, inject } from '@angular/core'; +import { ResourceAssetsService } from '@placeos/bookings'; import { Asset, AssetCategory, @@ -16,7 +17,6 @@ import { nextValueFrom, } from '@placeos/common'; import { - PlaceMetadata, PlaceZoneMetadata, authority, get, @@ -30,6 +30,7 @@ import { Observable, ReplaySubject, combineLatest, + forkJoin, of, timer, } from 'rxjs'; @@ -126,6 +127,7 @@ export class ExploreSearchService { private _settings = inject(SettingsService); private _maps_people = inject(MapsPeopleService); private _state = inject(ExploreStateService); + private _resourceAssets = inject(ResourceAssetsService); /** In-progress bookings/events for sorting priority */ private _in_progress_bookings = new ReplaySubject< @@ -161,7 +163,10 @@ export class ExploreSearchService { // Then get the asset type for that category switchMap((category) => { if (!category) return of(null as AssetGroup | null); - return queryAssetTypesLocal({ zone_id: bld.id, q: `"${category.name}"` }).pipe( + return queryAssetTypesLocal({ + zone_id: bld.id, + q: `"${category.name}"`, + }).pipe( catchError(() => of([] as AssetGroup[])), map( (groups) => @@ -192,16 +197,21 @@ export class ExploreSearchService { assets .filter((a) => a.asset_type_id === assetType.id) .map((a) => { - const zone = this._org.levelWithID(a.zones) || this._org.buildings.find(_ => a.zones.includes(_.id)) - return ({ + const zone = + this._org.levelWithID(a.zones) || + this._org.buildings.find((_) => + a.zones.includes(_.id), + ); + return { id: a.id, name: a.identifier || '', email: a.other_data?.email || '', phone: a.other_data?.phone || '', roles: a.other_data?.roles || [], zone: zone.id, - zone_name: zone?.display_name || zone?.name - }) + zone_name: + zone?.display_name || zone?.name, + }; }), ), ); @@ -282,28 +292,63 @@ export class ExploreSearchService { catchError(() => []), ); + /** Desk asset mapping for dual-source loading */ + private _deskAssetMapping = { + assetToResource: (asset: Asset, zone_id?: string): Desk => { + const other_data = asset.other_data as Record; + const desk = new Desk({ + id: asset.id, + map_id: other_data?.map_id || asset.id, + name: asset.identifier || '', + bookable: asset.bookable ?? true, + groups: other_data?.groups || [], + features: asset.features || [], + images: other_data?.images || [], + assigned_to: asset.assigned_to || '', + }); + (desk as any).notes = asset.notes || ''; + (desk as any).zone_id = zone_id || asset.zone_id; + return desk; + }, + resourceToAsset: () => ({}) as Partial, // Not needed for search + }; + private _desk_search: Observable = combineLatest([ this._org.active_building, ]).pipe( debounceTime(400), tap(() => this._loading.next(true)), - switchMap(([bld]) => - bld - ? listChildMetadata(bld.id, { name: 'desks' }).pipe( - catchError(() => of([] as PlaceMetadata[])), - map((i) => - flatten( - i.map((j) => - (j.metadata.desks?.details || []).map( - (k) => new Desk({ ...k, zone: j.zone }), - ), - ), - ), - ), - ) - : of([]), - ), - catchError(() => []), + switchMap(([bld]) => { + if (!bld) return of([]); + const levels = this._org.levelsForBuilding(bld); + if (!levels?.length) return of([]); + + // Load desks from all levels using dual-source loading + return forkJoin( + levels.map((lvl) => + this._resourceAssets + .loadWithFallback$( + 'desks', + 'desks', + lvl.id, + this._deskAssetMapping, + (item: any, zone_id: string) => + new Desk({ ...item, zone_id }), + ) + .pipe( + map((desks) => + desks.map((d) => { + const desk = d as Desk; + (desk as any).zone = lvl; + return desk; + }), + ), + catchError(() => of([] as Desk[])), + ), + ), + ).pipe(map((lists) => flatten(lists))); + }), + catchError(() => of([])), ); private _maps_people_search: Observable = combineLatest([ @@ -546,9 +591,8 @@ export class ExploreSearchService { ); // Get zones from in-progress bookings for proximity sorting - const in_progress_zones = this._getInProgressZones( - in_progress_bookings, - ); + const in_progress_zones = + this._getInProgressZones(in_progress_bookings); results.sort((a, b) => { // 1. If viewing a map, prioritize items on current level zone diff --git a/libs/explore/src/lib/explore-spaces.service.ts b/libs/explore/src/lib/explore-spaces.service.ts index 2018f3758e..6442154525 100644 --- a/libs/explore/src/lib/explore-spaces.service.ts +++ b/libs/explore/src/lib/explore-spaces.service.ts @@ -167,10 +167,16 @@ export class ExploreSpacesService extends AsyncHandler implements OnDestroy { const [email_start, email_end] = space.email.split('@'); const url = space.room_booking_url .replace(/\{id\}/g, encodeURIComponent(space.id)) - .replace(/\{name\}/g, encodeURIComponent(space.display_name || space.name)) + .replace( + /\{name\}/g, + encodeURIComponent(space.display_name || space.name), + ) .replace(/\{map_id\}/g, encodeURIComponent(space.map_id)) .replace(/\{email\}/g, encodeURIComponent(space.email)) - .replace(/\{email_start\}/g, encodeURIComponent(email_start || '')) + .replace( + /\{email_start\}/g, + encodeURIComponent(email_start || ''), + ) .replace(/\{email_end\}/g, encodeURIComponent(email_end || '')); window.open(url, '_blank', 'noopener noreferer'); return; diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 80586cf1bd..ccc60ab587 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -12,6 +12,9 @@ "CLEAR": "Clear", "ADD": "Add", "URL": "URL", + "MIGRATE": "Migrate", + "MIGRATION_SUCCESS": "Successfully migrated {{ count }} items.", + "MIGRATION_ERROR": "Failed to migrate items: {{ error }}", "SCHEDULED": "Scheduled", "CANCEL_BOOKING": "Cancel Booking", "REFRESH": "Refresh", @@ -1082,6 +1085,12 @@ "DESKS_BOOKING_DELETE_ERROR": "Failed to cancel desk booking. Error: {{ error }}", "DESKS_BOOKING_DELETE_SUCCESS": "Successfully cancelled desk booking.", "DESKS_SECURITY": "Security Group", + "DESKS_MIGRATE_TOOLTIP": "Migrate legacy desks to new system", + "DESKS_MIGRATE_TITLE": "Migrate Desks", + "DESKS_MIGRATE_MSG": "This will migrate all desks for this level to the Assets API. Continue?", + "DESKS_MIGRATE_LOADING": "Migrating desks...", + "DESKS_MIGRATE_ERROR": "Failed to migrate desks: {{ error }}", + "DESKS_MIGRATE_SUCCESS": "Successfully migrated desks.", "BOOKING_DELETED": "Cancelled", "BOOKING_ENDED": "Manually Ended", "BOOKING_EXPIRED": "Expired", @@ -1444,6 +1453,16 @@ "PARKING_COPIED_ID": "Parking Bay ID copied to clipboard", "PARKING_BOOKINGS_EMPTY": "No parking reservations for selected level and time", "PARKING_UNAVAILABLE": "No parking floors for the currently selected building", + "PARKING_SPACE_SAVE_ERROR": "Failed to save parking space: {{ error }}", + "PARKING_USER_SAVE_ERROR": "Failed to save parking user: {{ error }}", + "PARKING_SPACES_MIGRATE_TITLE": "Migrate Parking Spaces", + "PARKING_SPACES_MIGRATE_MSG": "This will migrate all parking spaces for this building to the Assets API. Continue?", + "PARKING_SPACES_MIGRATE_LOADING": "Migrating parking spaces...", + "PARKING_SPACES_MIGRATE_ERROR": "Failed to migrate parking spaces: {{ error }}", + "PARKING_USERS_MIGRATE_TITLE": "Migrate Parking Users", + "PARKING_USERS_MIGRATE_MSG": "This will migrate all parking users for this building to the Assets API. Continue?", + "PARKING_USERS_MIGRATE_LOADING": "Migrating parking users...", + "PARKING_USERS_MIGRATE_ERROR": "Failed to migrate parking users: {{ error }}", "EVENTS_HEADER": "Events", "EVENTS_ADD": "Add Event", "EVENTS_NEW": "New Group Event", @@ -1563,6 +1582,16 @@ "LOCKERS_NO_DRIVER": "Driver is not set up for lockers", "LOCKERS_POSITION_INVALID": "Position of the locker overlaps with another locker", "LOCKERS_SIZE_INVALID": "Locker overlaps with another locker", + "LOCKERS_SAVE_ERROR": "Failed to save locker: {{ error }}", + "LOCKERS_BANK_SAVE_ERROR": "Failed to save locker bank: {{ error }}", + "LOCKERS_BANKS_MIGRATE_TITLE": "Migrate Locker Banks", + "LOCKERS_BANKS_MIGRATE_MSG": "This will migrate all locker banks for this building to the Assets API. Continue?", + "LOCKERS_BANKS_MIGRATE_LOADING": "Migrating locker banks...", + "LOCKERS_BANKS_MIGRATE_ERROR": "Failed to migrate locker banks: {{ error }}", + "LOCKERS_MIGRATE_TITLE": "Migrate Lockers", + "LOCKERS_MIGRATE_MSG": "This will migrate all lockers for this building to the Assets API. Continue?", + "LOCKERS_MIGRATE_LOADING": "Migrating lockers...", + "LOCKERS_MIGRATE_ERROR": "Failed to migrate lockers: {{ error }}", "SIGNAGE_HEADER": "Digital Signage Management", "SIGNAGE_PLAYLIST": "Playlist", "SIGNAGE_PLAYLISTS": "Playlists", From ff40e8edd3e9d6e8d7bde2399b4fd888029348f3 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 22 Dec 2025 13:16:44 +1100 Subject: [PATCH 2/2] chore: fix builds --- .../workplace/src/app/landing/landing-favourites.component.ts | 4 ++-- .../src/lib/desk-select-modal/desk-details.component.ts | 4 ++-- .../new-parking-filters-display.component.ts | 3 +-- libs/components/src/lib/user-controls-sidebar.component.ts | 2 -- libs/form-fields/src/lib/user-list-field.component.ts | 2 -- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/apps/workplace/src/app/landing/landing-favourites.component.ts b/apps/workplace/src/app/landing/landing-favourites.component.ts index 7c9c24835a..47402cbd92 100644 --- a/apps/workplace/src/app/landing/landing-favourites.component.ts +++ b/apps/workplace/src/app/landing/landing-favourites.component.ts @@ -309,10 +309,10 @@ export class LandingFavouritesComponent extends AsyncHandler implements OnInit { return [ ...desks .filter(({ id }) => this.desks.includes(id)) - .map((_) => ({ ..._, type: 'desk' })), + .map((_) => ({ ..._, type: 'desk' as const })), ...parking .filter(({ id }) => this.parking_spaces.includes(id)) - .map((_) => ({ ..._, type: 'parking' })), + .map((_) => ({ ..._, type: 'parking' as const })), ]; }), tap((_) => console.log(_)), diff --git a/libs/bookings/src/lib/desk-select-modal/desk-details.component.ts b/libs/bookings/src/lib/desk-select-modal/desk-details.component.ts index f189466b59..8bf58acce6 100644 --- a/libs/bookings/src/lib/desk-select-modal/desk-details.component.ts +++ b/libs/bookings/src/lib/desk-select-modal/desk-details.component.ts @@ -74,7 +74,7 @@ import { BookingAsset } from '../booking-form.service'; >

- {{ desk().display_name || desk().name || desk().client_id || desk().id }} + {{ desk().display_name || desk().name || desk().id }}

@@ -89,7 +89,7 @@ import { BookingAsset } from '../booking-form.service'; desk

{{ - desk().display_name || desk().name || desk().client_id || desk().id + desk().display_name || desk().name || desk().id }}

diff --git a/libs/bookings/src/lib/new-parking-select-modal/new-parking-filters-display.component.ts b/libs/bookings/src/lib/new-parking-select-modal/new-parking-filters-display.component.ts index c74b6a71eb..49f8a52e77 100644 --- a/libs/bookings/src/lib/new-parking-select-modal/new-parking-filters-display.component.ts +++ b/libs/bookings/src/lib/new-parking-select-modal/new-parking-filters-display.component.ts @@ -8,7 +8,6 @@ import { SettingsService, } from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { BookingFormService } from '../booking-form.service'; @Component({ @@ -67,7 +66,7 @@ import { BookingFormService } from '../booking-form.service'; } `, ], - imports: [CommonModule, IconComponent, TranslatePipe, MatRippleModule], + imports: [CommonModule, IconComponent, MatRippleModule], }) export class NewParkingFiltersDisplayComponent extends AsyncHandler { private _event_form = inject(BookingFormService); diff --git a/libs/components/src/lib/user-controls-sidebar.component.ts b/libs/components/src/lib/user-controls-sidebar.component.ts index 0897a7cd2b..a2d346bfc6 100644 --- a/libs/components/src/lib/user-controls-sidebar.component.ts +++ b/libs/components/src/lib/user-controls-sidebar.component.ts @@ -4,7 +4,6 @@ import { CommonModule } from '@angular/common'; import { Component, inject, OnDestroy, signal, viewChild } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { IconComponent } from './icon.component'; -import { TranslatePipe } from './translate.pipe'; import { UserControlsComponent } from './user-controls.component'; @Component({ @@ -60,7 +59,6 @@ import { UserControlsComponent } from './user-controls.component'; MatRippleModule, IconComponent, UserControlsComponent, - TranslatePipe, ], }) export class UserControlsSidebarComponent implements OnDestroy { diff --git a/libs/form-fields/src/lib/user-list-field.component.ts b/libs/form-fields/src/lib/user-list-field.component.ts index e5656d2034..9c3284417a 100644 --- a/libs/form-fields/src/lib/user-list-field.component.ts +++ b/libs/form-fields/src/lib/user-list-field.component.ts @@ -50,7 +50,6 @@ import { searchGuests } from 'libs/users/src/lib/guests.fn'; import { NewUserModalComponent } from 'libs/users/src/lib/new-user-modal.component'; import { searchStaff } from 'libs/users/src/lib/staff.fn'; import { USER_DOMAIN } from 'libs/users/src/lib/user.utilities'; -import { PlaceUserPipe } from './place-user.pipe'; function validateEmail(email) { const re = @@ -237,7 +236,6 @@ const DENIED_FILE_TYPES = [ MatRippleModule, TranslatePipe, IconComponent, - PlaceUserPipe, MatTooltipModule, UserAvatarComponent, ],