From 1862cf8bb6c159f68eb07babc126a05dcdb20f0d Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Wed, 21 Oct 2026 19:36:32 +1100 Subject: [PATCH] feat(admin): configure tenant maximum booking length Edit staff-api booking_period per booking type as days, hours and minutes, stored as total minutes, and show it on the tenants table. Co-Authored-By: Claude Opus 5.5 --- e2e/src/admin.spec.ts | 111 ++++++++ e2e/src/pages/admin.page.ts | 55 ++++ public/assets/locale/en-AU.json | 14 + public/assets/locale/en-GB.json | 14 + public/assets/locale/en-US.json | 14 + src/app/admin/staff-api.component.ts | 38 ++- src/app/admin/staff-api.utilities.ts | 49 ++++ src/app/admin/staff-tenant-modal.component.ts | 258 +++++++++++++++++- src/tests/admin/staff-api.utilities.spec.ts | 39 ++- .../staff-tenant-modal.component.spec.ts | 70 +++++ 10 files changed, 659 insertions(+), 3 deletions(-) diff --git a/e2e/src/admin.spec.ts b/e2e/src/admin.spec.ts index 5c28e7b64..2b399bb31 100644 --- a/e2e/src/admin.spec.ts +++ b/e2e/src/admin.spec.ts @@ -1,3 +1,4 @@ +import { Page } from '@playwright/test'; import { expect, seedSession, test } from './fixtures/test-fixtures'; import { AdminPage } from './pages'; @@ -55,6 +56,25 @@ const API_KEYS = [ }, ]; +const TENANT = { + id: 'tenant-e2e', + name: 'E2E Tenant', + domain: 'place.tech', + platform: 'google', + delegated: true, + booking_limits: {}, + booking_range: {}, + booking_period: { desk: 240, parking: 1470, locker: 45 }, + early_checkin: 3600, +}; + +/** Tenant as saved by the mock staff API */ +async function storedTenant(page: Page): Promise { + return page.evaluate( + () => JSON.parse(sessionStorage.getItem('PLACEOS.mocks.tenants'))[0], + ); +} + test.describe('Admin', () => { let admin: AdminPage; @@ -174,6 +194,97 @@ test.describe('Admin', () => { const row = await admin.tableRow('Default Tenant', admin.section); await expect(row.nth(1)).toHaveText(/google/); }); + + test.describe('Maximum booking length', () => { + test.beforeEach(async ({ page }) => { + await seedSession(page, 'tenants', [TENANT]); + await admin.gotoSection('staff-api'); + }); + + test('AC-ADM-009-2: shows lengths without empty units', async () => { + await expect( + await admin.tenantBookingPeriods('E2E Tenant'), + ).toHaveText([ + 'desk: 4 hours', + 'parking: 1 day 30 minutes', + 'locker: 45 minutes', + ]); + }); + + test('AC-ADM-009-3: edits lengths as days, hours and minutes', async ({ + page, + }) => { + await admin.editTenant('E2E Tenant'); + + const rows = admin.bookingPeriodRows; + await expect(rows).toHaveCount(3); + await expect( + rows.nth(1).getByRole('spinbutton', { name: 'Days' }), + ).toHaveValue('1'); + await expect( + rows.nth(1).getByRole('combobox', { name: 'Hours' }), + ).toContainText('0 hours'); + await expect( + rows.nth(1).getByRole('combobox', { name: 'Minutes' }), + ).toContainText('30 minutes'); + + // desk: 4 hours => 4 hours 30 minutes + await admin.setBookingPeriod(0, { minutes: 30 }); + await expect( + admin.dialog.locator('[period-total]').first(), + ).toHaveText('4 hours 30 minutes'); + + // Remove locker and add a two day room booking length + await rows + .nth(2) + .getByRole('button', { name: 'Delete' }) + .click(); + await admin.dialog + .locator('[booking-period]') + .getByRole('button', { name: 'New item' }) + .click(); + await admin.setBookingPeriod(2, { + type: 'room', + days: 2, + hours: 0, + }); + + await admin.saveButton.click(); + await expect(admin.dialog).toBeHidden(); + + expect((await storedTenant(page)).booking_period).toEqual({ + desk: 270, + parking: 1470, + room: 2880, + }); + await expect( + await admin.tenantBookingPeriods('E2E Tenant'), + ).toHaveText([ + 'desk: 4 hours 30 minutes', + 'parking: 1 day 30 minutes', + 'room: 2 days', + ]); + }); + + test('AC-ADM-009-4: does not save a zero length', async ({ + page, + }) => { + await admin.editTenant('E2E Tenant'); + + await admin.setBookingPeriod(2, { minutes: 0 }); + await expect( + admin.dialog.getByText( + 'Enter a unique booking type and a length greater than 0 for each row.', + ), + ).toBeVisible(); + + await admin.saveButton.click(); + await expect(admin.dialog).toBeVisible(); + expect((await storedTenant(page)).booking_period).toEqual( + TENANT.booking_period, + ); + }); + }); }); test.describe('US-ADM-011: Manage API Keys', () => { diff --git a/e2e/src/pages/admin.page.ts b/e2e/src/pages/admin.page.ts index ad88ae466..477708ee6 100644 --- a/e2e/src/pages/admin.page.ts +++ b/e2e/src/pages/admin.page.ts @@ -105,4 +105,59 @@ export class AdminPage extends BasePage { .first() .click(); } + + /** Maximum booking lengths of a tenant in the tenants table */ + async tenantBookingPeriods(name: string): Promise { + const row = await this.tableRow(name, this.section); + return row.locator('[booking-period] > div'); + } + + /** Open the edit modal of a tenant */ + async editTenant(name: string): Promise { + const row = await this.tableRow(name, this.section); + await row.getByRole('button', { name: 'Edit Tenant' }).click(); + await expect(this.dialog).toBeVisible(); + } + + /** Rows of the maximum booking length editor in the tenant modal */ + get bookingPeriodRows(): Locator { + return this.dialog.locator('[period-row]'); + } + + /** Set the maximum booking length of a row in the tenant modal */ + async setBookingPeriod( + index: number, + period: { + type?: string; + days?: number; + hours?: number; + minutes?: number; + }, + ): Promise { + const row = this.bookingPeriodRows.nth(index); + if (period.type != null) { + await row + .getByRole('textbox', { name: 'Booking type' }) + .fill(period.type); + } + if (period.days != null) { + await row + .getByRole('spinbutton', { name: 'Days' }) + .fill(`${period.days}`); + } + const selects: [string, number | undefined, string][] = [ + ['Hours', period.hours, 'hour'], + ['Minutes', period.minutes, 'minute'], + ]; + for (const [name, value, unit] of selects) { + if (value == null) continue; + await row.getByRole('combobox', { name }).click(); + await this.page + .getByRole('option', { + name: `${value} ${unit}${value === 1 ? '' : 's'}`, + exact: true, + }) + .click(); + } + } } diff --git a/public/assets/locale/en-AU.json b/public/assets/locale/en-AU.json index 49fe9fd03..fb192147f 100644 --- a/public/assets/locale/en-AU.json +++ b/public/assets/locale/en-AU.json @@ -1089,6 +1089,20 @@ "TENANTS_MINUTES": "{{ minute }} minutes", "TENANTS_HOURS": "{{ hour }} hours", "TENANTS_MINUTES_HOURS": "{{ hour }} hours {{ minute }} minutes", + "TENANTS_BOOKING_PERIOD": "Maximum Booking Length", + "TENANTS_BOOKING_PERIOD_COLUMN": "Max Length", + "TENANTS_BOOKING_PERIOD_HINT": "Longest booking allowed for each booking type, such as desk. Remove a row to allow bookings of any length for that type.", + "TENANTS_BOOKING_PERIOD_ERROR": "Enter a unique booking type and a length greater than 0 for each row.", + "TENANTS_BOOKING_TYPE": "Booking type", + "TENANTS_DURATION_DAYS": "{{ count }} days", + "TENANTS_DURATION_DAYS_1": "{{ count }} day", + "TENANTS_DURATION_HOURS": "{{ count }} hours", + "TENANTS_DURATION_HOURS_1": "{{ count }} hour", + "TENANTS_DURATION_MINUTES": "{{ count }} minutes", + "TENANTS_DURATION_MINUTES_1": "{{ count }} minute", + "TENANTS_DAYS": "Days", + "TENANTS_HOURS_LABEL": "Hours", + "TENANTS_MINUTES_LABEL": "Minutes", "TENANTS_SAVE_ERROR": "Failed to update tenant.", "TENANTS_SAVE_SUCCESS": "Successfully updated tenant.", "RESOURCE_IMPORTS_HEADER": "Resource Imports", diff --git a/public/assets/locale/en-GB.json b/public/assets/locale/en-GB.json index 54f758772..90d112ae3 100644 --- a/public/assets/locale/en-GB.json +++ b/public/assets/locale/en-GB.json @@ -872,6 +872,20 @@ "TENANTS_MINUTES": "{{ minute }} minutes", "TENANTS_HOURS": "{{ hour }} hours", "TENANTS_MINUTES_HOURS": "{{ hour }} hours {{ minute }} minutes", + "TENANTS_BOOKING_PERIOD": "Maximum Booking Length", + "TENANTS_BOOKING_PERIOD_COLUMN": "Max Length", + "TENANTS_BOOKING_PERIOD_HINT": "Longest booking allowed for each booking type, such as desk. Remove a row to allow bookings of any length for that type.", + "TENANTS_BOOKING_PERIOD_ERROR": "Enter a unique booking type and a length greater than 0 for each row.", + "TENANTS_BOOKING_TYPE": "Booking type", + "TENANTS_DURATION_DAYS": "{{ count }} days", + "TENANTS_DURATION_DAYS_1": "{{ count }} day", + "TENANTS_DURATION_HOURS": "{{ count }} hours", + "TENANTS_DURATION_HOURS_1": "{{ count }} hour", + "TENANTS_DURATION_MINUTES": "{{ count }} minutes", + "TENANTS_DURATION_MINUTES_1": "{{ count }} minute", + "TENANTS_DAYS": "Days", + "TENANTS_HOURS_LABEL": "Hours", + "TENANTS_MINUTES_LABEL": "Minutes", "TENANTS_SAVE_ERROR": "Failed to update tenant.", "TENANTS_SAVE_SUCCESS": "Successfully updated tenant.", "RESOURCE_IMPORTS_HEADER": "Resource Imports", diff --git a/public/assets/locale/en-US.json b/public/assets/locale/en-US.json index fc048ed1c..2d6afc3dd 100644 --- a/public/assets/locale/en-US.json +++ b/public/assets/locale/en-US.json @@ -869,6 +869,20 @@ "TENANTS_MINUTES": "{{ minute }} minutes", "TENANTS_HOURS": "{{ hour }} hours", "TENANTS_MINUTES_HOURS": "{{ hour }} hours {{ minute }} minutes", + "TENANTS_BOOKING_PERIOD": "Maximum Booking Length", + "TENANTS_BOOKING_PERIOD_COLUMN": "Max Length", + "TENANTS_BOOKING_PERIOD_HINT": "Longest booking allowed for each booking type, such as desk. Remove a row to allow bookings of any length for that type.", + "TENANTS_BOOKING_PERIOD_ERROR": "Enter a unique booking type and a length greater than 0 for each row.", + "TENANTS_BOOKING_TYPE": "Booking type", + "TENANTS_DURATION_DAYS": "{{ count }} days", + "TENANTS_DURATION_DAYS_1": "{{ count }} day", + "TENANTS_DURATION_HOURS": "{{ count }} hours", + "TENANTS_DURATION_HOURS_1": "{{ count }} hour", + "TENANTS_DURATION_MINUTES": "{{ count }} minutes", + "TENANTS_DURATION_MINUTES_1": "{{ count }} minute", + "TENANTS_DAYS": "Days", + "TENANTS_HOURS_LABEL": "Hours", + "TENANTS_MINUTES_LABEL": "Minutes", "TENANTS_SAVE_ERROR": "Failed to update tenant.", "TENANTS_SAVE_SUCCESS": "Successfully updated tenant.", "RESOURCE_IMPORTS_HEADER": "Resource Imports", diff --git a/src/app/admin/staff-api.component.ts b/src/app/admin/staff-api.component.ts index a7e8bf743..4cacbb790 100644 --- a/src/app/admin/staff-api.component.ts +++ b/src/app/admin/staff-api.component.ts @@ -19,7 +19,11 @@ import { SimpleTableComponent } from '../ui/simple-table.component'; import { TranslatePipe } from '../ui/translate.pipe'; import { AdminDataService } from './admin-data.service'; import { BookingLimitsModalComponent } from './booking-limits-modal.component'; -import { isSecretExpired, isSecretExpiring } from './staff-api.utilities'; +import { + formatMinutes, + isSecretExpired, + isSecretExpiring, +} from './staff-api.utilities'; import { StaffTenantModalComponent } from './staff-tenant-modal.component'; export interface PlaceTenant { @@ -32,6 +36,8 @@ export interface PlaceTenant { service_account?: string; booking_limits: Record; booking_range?: Record; + /** Booking type => maximum length of a booking in minutes */ + booking_period?: Record; credentials?: HashMap; secret_expiry?: number; early_checkin?: number; @@ -91,6 +97,14 @@ export interface PlaceTenant { key: 'platform', name: 'ADMIN.TENANTS_PLATFORM' | translate, }, + { + key: 'booking_period', + name: + 'ADMIN.TENANTS_BOOKING_PERIOD_COLUMN' + | translate, + content: period_template, + sortable: false, + }, { key: 'secret_expiry', name: 'ADMIN.TENANTS_SECRET_EXPIRY' | translate, @@ -135,6 +149,16 @@ export interface PlaceTenant { + +
+ @for (period of bookingPeriods(data); track period.type) { +
+ {{ period.type }}: + {{ period.length }} +
+ } +
+
+
+ {{ + 'ADMIN.TENANTS_BOOKING_PERIOD' | translate + }} +

+ {{ 'ADMIN.TENANTS_BOOKING_PERIOD_HINT' | translate }} +

+ @for ( + item of formModel().booking_period; + track $index; + let index = $index + ) { +
+ + + + + + {{ + 'ADMIN.TENANTS_DAYS' | translate + }} + + + + @for (hour of hour_options; track hour) { + + {{ + 'ADMIN.TENANTS_DURATION_HOURS' + | translate + : { count: hour } + : hour + }} + + } + + + + + @for ( + minute of minuteOptions(item.minutes); + track minute + ) { + + {{ + 'ADMIN.TENANTS_DURATION_MINUTES' + | translate + : { count: minute } + : minute + }} + + } + + + +
+

+ {{ formatPeriod(item) }} +

+ } + + @if (form.booking_period().invalid()) { +

+ {{ + 'ADMIN.TENANTS_BOOKING_PERIOD_ERROR' | translate + }} +

+ } +
`, @@ -410,6 +591,8 @@ export interface StaffTenantModalData { ], imports: [ FullscreenModalShellComponent, + IconComponent, + MatRippleModule, ObjectListFieldComponent, FormField, TranslatePipe, @@ -459,13 +642,32 @@ export class StaffTenantModalComponent { `${days}`.trim() === '' || !Number.isInteger(+days) || +days < 0 || - +days > 4294967295, + +days > MAX_TENANT_LIMIT, ) || new Set(types).size !== types.length ? { kind: 'booking_range' } : undefined; }); + validate(path.booking_period, ({ value }) => { + const periods = value(); + const types = periods.map(({ type }) => type?.trim()); + return periods.some(({ days }, index) => { + const total = joinDuration({ ...periods[index], days: +days }); + return ( + !types[index] || + `${days ?? ''}`.trim() === '' || + !Number.isInteger(+days) || + +days < 0 || + total <= 0 || + total > MAX_TENANT_LIMIT + ); + }) || new Set(types).size !== types.length + ? { kind: 'booking_period' } + : undefined; + }); }); + public readonly hour_options = HOUR_OPTIONS; + public readonly credential_fields = computed(() => { const model = this.formModel(); const required = !model.id && !model.delegated; @@ -520,6 +722,51 @@ export class StaffTenantModalComponent { })); } + /** Minute choices, keeping a stored value that isn't a multiple of 5 */ + public minuteOptions(current: number) { + return MINUTE_OPTIONS.includes(current) + ? MINUTE_OPTIONS + : [...MINUTE_OPTIONS, current].sort((a, b) => a - b); + } + + public formatPeriod(period: BookingPeriodRow) { + return formatMinutes(joinDuration({ ...period, days: +period.days })); + } + + public addBookingPeriod() { + this.updateBookingPeriods((periods) => [ + ...periods, + { type: '', days: 0, hours: 1, minutes: 0 }, + ]); + } + + public removeBookingPeriod(index: number) { + this.updateBookingPeriods((periods) => + periods.filter((_, item_index) => item_index !== index), + ); + } + + public updateBookingPeriod( + index: number, + field: K, + value: BookingPeriodRow[K], + ) { + this.updateBookingPeriods((periods) => + periods.map((item, item_index) => + item_index === index ? { ...item, [field]: value } : item, + ), + ); + } + + private updateBookingPeriods( + change: (periods: BookingPeriodRow[]) => BookingPeriodRow[], + ) { + this.formModel.update((model) => ({ + ...model, + booking_period: change(model.booking_period), + })); + } + public async save() { await submit(this.form, async () => undefined); const invalid_credentials = this.credential_fields() @@ -553,6 +800,12 @@ export class StaffTenantModalComponent { +days, ]), ), + booking_period: Object.fromEntries( + model.booking_period.map((period) => [ + period.type.trim(), + joinDuration({ ...period, days: +period.days }), + ]), + ), }; if (!(value.credentials as HashMap).conference_type) { delete (value.credentials as HashMap).conference_type; @@ -635,6 +888,9 @@ export class StaffTenantModalComponent { booking_range: Object.entries(this.tenant?.booking_range || {}).map( ([type, days]) => ({ type, days }), ), + booking_period: Object.entries( + this.tenant?.booking_period || {}, + ).map(([type, minutes]) => ({ type, ...splitMinutes(minutes) })), early_checkin: this.tenant?.early_checkin || 60 * 60, credentials: { user_agent: 'PlaceOS', diff --git a/src/tests/admin/staff-api.utilities.spec.ts b/src/tests/admin/staff-api.utilities.spec.ts index e4838ad5c..f65a858df 100644 --- a/src/tests/admin/staff-api.utilities.spec.ts +++ b/src/tests/admin/staff-api.utilities.spec.ts @@ -1,11 +1,22 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { PlaceTenant } from '../../app/admin/staff-api.component'; import { + formatMinutes, isSecretExpired, isSecretExpiring, + joinDuration, + splitMinutes, tenantExpiryBanner, } from '../../app/admin/staff-api.utilities'; +// `ADMIN.TENANTS_DURATION_HOURS` => `2 hours`, or `1 hour` when plural is 1 +vi.mock('../../app/common/locale.service', () => ({ + i18n: (key: string, args: { count: number }, plural?: number) => { + const unit = key.split('_').pop().toLowerCase(); + return `${args.count} ${plural === 1 ? unit.slice(0, -1) : unit}`; + }, +})); + const NOW = new Date('2026-06-01T00:00:00Z').valueOf(); const DAY = 24 * 60 * 60; const now_s = NOW / 1000; @@ -44,4 +55,30 @@ describe('staff-api.utilities', () => { tenantExpiryBanner([tenant('a', now_s + 90 * DAY)], NOW), ).toBeNull(); }); + + it.each([ + [0, { days: 0, hours: 0, minutes: 0 }], + [45, { days: 0, hours: 0, minutes: 45 }], + [270, { days: 0, hours: 4, minutes: 30 }], + [1440, { days: 1, hours: 0, minutes: 0 }], + [3061, { days: 2, hours: 3, minutes: 1 }], + ])('splits %i minutes into days, hours and minutes', (total, parts) => { + expect(splitMinutes(total)).toEqual(parts); + expect(joinDuration(parts)).toBe(total); + }); + + it.each([ + [0, '0 minutes'], + [1, '1 minute'], + [30, '30 minutes'], + [60, '1 hour'], + [240, '4 hours'], + [270, '4 hours 30 minutes'], + [1440, '1 day'], + [1470, '1 day 30 minutes'], + [2 * 1440 + 60, '2 days 1 hour'], + [1440 + 61, '1 day 1 hour 1 minute'], + ])('formats %i minutes as "%s"', (total, label) => { + expect(formatMinutes(total)).toBe(label); + }); }); diff --git a/src/tests/admin/staff-tenant-modal.component.spec.ts b/src/tests/admin/staff-tenant-modal.component.spec.ts index 042852b2e..8f13fbaad 100644 --- a/src/tests/admin/staff-tenant-modal.component.spec.ts +++ b/src/tests/admin/staff-tenant-modal.component.spec.ts @@ -28,6 +28,7 @@ describe('Staff tenant advance booking limits', () => { delegated: true, booking_limits: { desk: 2 }, booking_range: { desk: 30, parking: 0 }, + booking_period: { desk: 270, parking: 1500 }, }; beforeEach(() => vi.clearAllMocks()); @@ -136,4 +137,73 @@ describe('Staff tenant advance booking limits', () => { } expect(put).not.toHaveBeenCalled(); }); + + it('loads booking periods as days, hours and minutes', async () => { + const component = await createComponent({ tenant }); + expect(component.formModel().booking_period).toEqual([ + { type: 'desk', days: 0, hours: 4, minutes: 30 }, + { type: 'parking', days: 1, hours: 1, minutes: 0 }, + ]); + }); + + it('saves booking periods as total minutes', async () => { + const component = await createComponent({ tenant }); + component.updateBookingPeriod(0, 'hours', 2); + component.updateBookingPeriod(0, 'minutes', 15); + component.updateBookingPeriod(1, 'days', '2'); + component.addBookingPeriod(); + component.updateBookingPeriod(2, 'type', ' locker '); + await component.save(); + expect(put).toHaveBeenCalledWith( + '/api/staff/v1/tenants/tenant-test', + expect.objectContaining({ + booking_range: { desk: 30, parking: 0 }, + booking_period: { desk: 135, parking: 2940, locker: 60 }, + }), + ); + }); + + it('sends an empty map when all booking periods are removed', async () => { + const component = await createComponent({ tenant }); + component.removeBookingPeriod(1); + component.removeBookingPeriod(0); + await component.save(); + expect(put).toHaveBeenCalledWith( + '/api/staff/v1/tenants/tenant-test', + expect.objectContaining({ booking_period: {} }), + ); + }); + + it('keeps stored minutes that are not a multiple of 5 selectable', async () => { + const component = await createComponent(); + expect(component.minuteOptions(30)).not.toContain(7); + expect(component.minuteOptions(7)).toEqual([ + 0, 5, 7, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, + ]); + }); + + it('blocks empty lengths, invalid days, missing and duplicate types', async () => { + const component = await createComponent({ tenant }); + const invalid_periods = [ + [{ type: 'desk', days: 0, hours: 0, minutes: 0 }], + [{ type: 'desk', days: '', hours: 1, minutes: 0 }], + [{ type: 'desk', days: '-1', hours: 1, minutes: 0 }], + [{ type: 'desk', days: '1.5', hours: 0, minutes: 0 }], + [{ type: 'desk', days: '2982617', hours: 0, minutes: 0 }], + [{ type: '', days: 1, hours: 0, minutes: 0 }], + [ + { type: 'desk', days: 1, hours: 0, minutes: 0 }, + { type: ' desk ', days: 0, hours: 2, minutes: 0 }, + ], + ]; + for (const periods of invalid_periods) { + component.formModel.update((model) => ({ + ...model, + booking_period: periods, + })); + await component.save(); + expect(component.form.booking_period().invalid()).toBe(true); + } + expect(put).not.toHaveBeenCalled(); + }); });