+ }
@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 fc0ed6acd5..b71ff578a7 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 48abd67022..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,
@@ -123,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 {
@@ -137,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 */
@@ -299,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/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/index.ts b/libs/bookings/src/index.ts
index 292a7e80ef..7178c8af73 100644
--- a/libs/bookings/src/index.ts
+++ b/libs/bookings/src/index.ts
@@ -14,6 +14,7 @@ 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';
diff --git a/libs/bookings/src/lib/booking-form.service.ts b/libs/bookings/src/lib/booking-form.service.ts
index 5c403eb317..a9a9875930 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,
@@ -71,12 +72,64 @@ import {
} from './bookings.fn';
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'];
@@ -129,6 +182,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',
});
@@ -990,8 +1044,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
@@ -1001,25 +1144,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-list.component.ts b/libs/bookings/src/lib/desk-select-modal/desk-list.component.ts
index 63709b7d50..fbdea95790 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/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/explore/src/lib/explore-desks.service.ts b/libs/explore/src/lib/explore-desks.service.ts
index 5399a9c92c..55bbe5ce5b 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 02363cd0a2..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<
@@ -290,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([
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,
],
diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json
index 016f349c7f..17eab8d861 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",