Skip to content

Commit bf2cc8a

Browse files
committed
feat(concierge): add setting to limit assigned desks/parking
1 parent 6bf5ea1 commit bf2cc8a

18 files changed

Lines changed: 376 additions & 3 deletions

apps/concierge/src/app/desks/desks-state.service.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,23 @@ export class DesksStateService extends AsyncHandler {
333333
const idx = desk_list.findIndex((_) => _.id === desk.id);
334334
if (idx >= 0) desk_list[idx] = new_desk;
335335
else desk_list.push(new_desk);
336+
if (
337+
new_desk.assigned_to &&
338+
(desk.assigned_to !== new_desk.assigned_to || desk.id !== new_desk.id)
339+
) {
340+
try {
341+
await this._checkAssignedDeskLimit(
342+
new_desk.assigned_to,
343+
desk.id,
344+
);
345+
} catch (error) {
346+
notifyError(
347+
error instanceof Error ? error.message : `${error}`,
348+
);
349+
ref.componentInstance.loading.set(false);
350+
throw error;
351+
}
352+
}
336353
await lastValueFrom(
337354
updateMetadata(zone, {
338355
name: 'desks',
@@ -583,6 +600,50 @@ export class DesksStateService extends AsyncHandler {
583600
resp.close();
584601
}
585602

603+
private async _checkAssignedDeskLimit(
604+
user_email: string,
605+
current_desk_id?: string,
606+
) {
607+
const max_assigned_count = Math.max(
608+
Number(this._settings.get('app.desks.max_assigned_count')) || 0,
609+
0,
610+
);
611+
if (!max_assigned_count || !user_email) return;
612+
const email = user_email.toLowerCase();
613+
const assigned_count = (
614+
await Promise.all(
615+
this._currentLevelList().map((level) =>
616+
lastValueFrom(
617+
showMetadata(level.id, 'desks').pipe(
618+
map((metadata) =>
619+
metadata.details instanceof Array
620+
? metadata.details
621+
: [],
622+
),
623+
catchError(() => of([])),
624+
),
625+
),
626+
),
627+
)
628+
)
629+
.flat()
630+
.filter(
631+
(item: Partial<Desk>) =>
632+
item.id !== current_desk_id &&
633+
item.assigned_to?.toLowerCase() === email,
634+
).length;
635+
if (assigned_count >= max_assigned_count) {
636+
const key =
637+
max_assigned_count === 1
638+
? 'APP.CONCIERGE.DESKS_ASSIGN_LIMIT_ERROR_1'
639+
: 'APP.CONCIERGE.DESKS_ASSIGN_LIMIT_ERROR_N';
640+
const message = i18n(key, { count: max_assigned_count });
641+
throw !message || message === key
642+
? `Users can only have ${max_assigned_count} assigned desk${max_assigned_count === 1 ? '' : 's'} at a time.`
643+
: message;
644+
}
645+
}
646+
586647
private async _rollbackMetadata(zone: string, original_desk_list: any[]) {
587648
try {
588649
await lastValueFrom(

apps/concierge/src/app/parking/parking-state.service.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
} from 'date-fns';
6464
import { BehaviorSubject, combineLatest, lastValueFrom, of } from 'rxjs';
6565
import {
66+
catchError,
6667
debounceTime,
6768
filter,
6869
first,
@@ -460,6 +461,12 @@ export class ParkingStateService extends AsyncHandler {
460461
notes: row.notes || '',
461462
...(!row.id ? { zone_id } : {}),
462463
};
464+
if (space_data.assigned_to) {
465+
await this._checkAssignedParkingLimit(
466+
space_data.assigned_to,
467+
space_data.id,
468+
);
469+
}
463470
await saveParkingSpace(space_data).toPromise();
464471
success_count++;
465472
} catch (e) {
@@ -509,6 +516,24 @@ export class ParkingStateService extends AsyncHandler {
509516
zone_id,
510517
id: state.metadata.id || undefined,
511518
};
519+
if (
520+
asset_data.assigned_to &&
521+
(space.assigned_to !== asset_data.assigned_to ||
522+
space.id !== asset_data.id)
523+
) {
524+
try {
525+
await this._checkAssignedParkingLimit(
526+
asset_data.assigned_to,
527+
space.id,
528+
);
529+
} catch (error) {
530+
notifyError(
531+
error instanceof Error ? error.message : `${error}`,
532+
);
533+
ref.componentInstance.loading.set(false);
534+
throw error;
535+
}
536+
}
512537
let recreate = false;
513538
if (
514539
space.assigned_to &&
@@ -895,6 +920,44 @@ export class ParkingStateService extends AsyncHandler {
895920
);
896921
}
897922

923+
private async _checkAssignedParkingLimit(
924+
user_email: string,
925+
current_space_id?: string,
926+
) {
927+
const max_assigned_count = Math.max(
928+
Number(this._settings.get('app.parking.max_assigned_count')) || 0,
929+
0,
930+
);
931+
if (!max_assigned_count || !user_email) return;
932+
const level_ids = this._org
933+
.levelsForBuilding(this._org.building)
934+
.filter((level) => level.tags.includes('parking'))
935+
.map((level) => level.id);
936+
if (!level_ids.length) return;
937+
const email = user_email.toLowerCase();
938+
const assigned_count = (
939+
await lastValueFrom(
940+
queryParkingSpacesForZones(level_ids).pipe(
941+
catchError(() => of([])),
942+
),
943+
)
944+
).filter(
945+
(space) =>
946+
space.id !== current_space_id &&
947+
space.assigned_to?.toLowerCase() === email,
948+
).length;
949+
if (assigned_count >= max_assigned_count) {
950+
const key =
951+
max_assigned_count === 1
952+
? 'APP.CONCIERGE.PARKING_ASSIGN_LIMIT_ERROR_1'
953+
: 'APP.CONCIERGE.PARKING_ASSIGN_LIMIT_ERROR_N';
954+
const message = i18n(key, { count: max_assigned_count });
955+
throw !message || message === key
956+
? `Users can only have ${max_assigned_count} assigned parking space${max_assigned_count === 1 ? '' : 's'} at a time.`
957+
: message;
958+
}
959+
}
960+
898961
private async _clearAssignedBooking(resource: ParkingSpace) {
899962
const today = Date.now();
900963
const booking_list = await lastValueFrom(

apps/concierge/src/app/ui/app-settings/concierge-settings-form-modal.component.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,61 @@ import { UploadButtonComponent } from './upload-button.component';
840840
</div>
841841
</section>
842842
}
843+
@if (form.value.features.includes('desks')) {
844+
<section
845+
desks
846+
id="feature-desks"
847+
class="border-base-300 relative rounded-sm border"
848+
formGroupName="desks"
849+
>
850+
<h3
851+
class="bg-base-100 absolute top-0 left-4 -translate-y-1/2 rounded-sm px-2 py-1 font-medium"
852+
>
853+
Desk Assignments
854+
</h3>
855+
<button
856+
icon
857+
matRipple
858+
class="bg-base-100 absolute top-0 right-4 -translate-y-1/2"
859+
(click)="toggleGroup('desks')"
860+
>
861+
<icon>{{
862+
shown_group() === 'desks'
863+
? 'chevron_left'
864+
: 'keyboard_arrow_down'
865+
}}</icon>
866+
</button>
867+
<div
868+
collapsible
869+
[class.open]="shown_group() === 'desks'"
870+
>
871+
<div class="content px-4 pt-4 pb-2">
872+
<div>
873+
<label for="desks-max-assigned-count">
874+
Max Assigned Desks Per User
875+
</label>
876+
<mat-form-field
877+
appearance="outline"
878+
class="w-full"
879+
>
880+
<input
881+
matInput
882+
type="number"
883+
min="0"
884+
name="desks-max-assigned-count"
885+
formControlName="max_assigned_count"
886+
/>
887+
<mat-hint>
888+
Maximum number of desk assignments a
889+
user can have at one time. Set to 0
890+
for unlimited.
891+
</mat-hint>
892+
</mat-form-field>
893+
</div>
894+
</div>
895+
</div>
896+
</section>
897+
}
843898
@if (form.value.features.includes('visitors')) {
844899
<section
845900
visitors
@@ -1306,6 +1361,28 @@ import { UploadButtonComponent } from './upload-button.component';
13061361
</mat-hint>
13071362
</mat-form-field>
13081363
</div>
1364+
<div>
1365+
<label for="parking-max-assigned-count">
1366+
Max Assigned Parking Spaces Per User
1367+
</label>
1368+
<mat-form-field
1369+
appearance="outline"
1370+
class="w-full"
1371+
>
1372+
<input
1373+
matInput
1374+
type="number"
1375+
min="0"
1376+
name="parking-max-assigned-count"
1377+
formControlName="max_assigned_count"
1378+
/>
1379+
<mat-hint>
1380+
Maximum number of parking space
1381+
assignments a user can have at one
1382+
time. Set to 0 for unlimited.
1383+
</mat-hint>
1384+
</mat-form-field>
1385+
</div>
13091386
<div
13101387
class="grid grid-cols-1 gap-4 md:grid-cols-2"
13111388
formGroupName="bookable_hours"
@@ -1688,6 +1765,9 @@ export class ConciergeSettingsFormModalComponent implements OnInit {
16881765
allow_visibility: new FormControl(false),
16891766
allow_edit: new FormControl(true),
16901767
}),
1768+
desks: new FormGroup({
1769+
max_assigned_count: new FormControl(0),
1770+
}),
16911771
visitors: new FormGroup({
16921772
bookable_hours: new FormGroup({
16931773
start: new FormControl<number | null>(null),
@@ -1731,6 +1811,7 @@ export class ConciergeSettingsFormModalComponent implements OnInit {
17311811
assign_space_on_approve: new FormControl(false),
17321812
available_period: new FormControl(7),
17331813
max_duration: new FormControl(480),
1814+
max_assigned_count: new FormControl(0),
17341815
}),
17351816
lockers: new FormGroup({
17361817
allow_all_day: new FormControl(true),

apps/concierge/src/environments/settings.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,14 @@ const app = {
3535
delegated: false,
3636
has_uploads: true,
3737
custom_reports,
38-
desks: { can_book_for_others: true },
38+
desks: { can_book_for_others: true, max_assigned_count: 0 },
3939
bookings: { can_book_for_others: true, use_building_timezone: false },
4040
parking: {
4141
show_waitlist: false,
4242
hide_bay_number: false,
4343
hide_assign_space: false,
4444
assign_space_on_approve: false,
45+
max_assigned_count: 0,
4546
},
4647
events: {
4748
allow_setup_breakdown: false,

apps/concierge/src/tests/desks/desks-state.service.spec.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ describe('DesksStateService', () => {
2323
let active_building: BehaviorSubject<any>;
2424
let active_region: BehaviorSubject<any>;
2525
let current_building: any;
26+
let settings_map: Record<string, any>;
2627
const organisation_service: any = {
2728
active_levels: of([]),
2829
initialised: of(true),
@@ -46,15 +47,15 @@ describe('DesksStateService', () => {
4647
providers: [
4748
MockProvider(MatDialog, { open: jest.fn() }),
4849
MockProvider(SettingsService, {
49-
get: ((name: string) =>
50-
name === 'app.use_region' ? false : undefined) as any,
50+
get: ((name: string) => settings_map[name]) as any,
5151
} as any),
5252
MockProvider(OrganisationService, organisation_service),
5353
],
5454
});
5555

5656
beforeEach(() => {
5757
current_building = { id: 'bld-1' };
58+
settings_map = { 'app.use_region': false };
5859
active_building = new BehaviorSubject(current_building);
5960
active_region = new BehaviorSubject({ id: 'region-1' });
6061
organisation_service.active_building = active_building;
@@ -68,6 +69,9 @@ describe('DesksStateService', () => {
6869
jest.spyOn(ts_client_mod, 'updateMetadata').mockReturnValue(
6970
of({}) as any,
7071
);
72+
jest.spyOn(ts_client_mod, 'showMetadata').mockReturnValue(
73+
of({ details: [] }) as any,
74+
);
7175
(component_mod as any).openConfirmModal = jest.fn(async () => ({
7276
reason: 'done',
7377
loading: jest.fn(),
@@ -79,6 +83,7 @@ describe('DesksStateService', () => {
7983
(common_mod as any).notifySuccess = jest.fn();
8084
(common_mod as any).notifyError = jest.fn();
8185
(common_mod as any).unique = jest.fn((list) => list);
86+
jest.clearAllMocks();
8287
spectator = createService();
8388
});
8489

@@ -180,6 +185,51 @@ describe('DesksStateService', () => {
180185
expect(booking_mod.saveBooking).toHaveBeenCalled();
181186
});
182187

188+
it('should block assignments when the desk limit is reached', async () => {
189+
settings_map['app.desks.max_assigned_count'] = 1;
190+
(ts_client_mod.showMetadata as jest.Mock).mockReturnValue(
191+
of({
192+
details: [
193+
{
194+
id: 'desk-existing',
195+
assigned_to: 'staff@example.com',
196+
assigned_name: 'Staff Name',
197+
},
198+
],
199+
}) as any,
200+
);
201+
const dialog_ref = {
202+
afterClosed: () =>
203+
of({
204+
reason: 'done',
205+
metadata: {
206+
id: 'desk-new',
207+
name: 'Desk New',
208+
assigned_to: 'staff@example.com',
209+
assigned_name: 'Staff Name',
210+
},
211+
}),
212+
componentInstance: {
213+
event: new EventEmitter<any>(),
214+
loading: { set: jest.fn() },
215+
},
216+
close: jest.fn(),
217+
};
218+
(spectator.inject(MatDialog).open as any).mockReturnValue(dialog_ref);
219+
spectator.service.setFilters({ zones: ['level-1'] });
220+
221+
await spectator.service.editDesk({ id: 'desk-new-2' } as any).catch(() => undefined);
222+
223+
expect(common_mod.notifyError).toHaveBeenCalledWith(
224+
'Users can only have 1 assigned desk at a time.',
225+
);
226+
expect(ts_client_mod.updateMetadata).not.toHaveBeenCalled();
227+
expect(booking_mod.saveBooking).not.toHaveBeenCalled();
228+
expect(dialog_ref.componentInstance.loading.set).toHaveBeenCalledWith(
229+
false,
230+
);
231+
});
232+
183233
it.todo('should handle loading desk bookings');
184234
it.todo('should handle loading desk list');
185235
it.todo('should handle filtering of desk bookings');

0 commit comments

Comments
 (0)