diff --git a/apps/concierge/src/app/reports/reports-menu.component.ts b/apps/concierge/src/app/reports/reports-menu.component.ts index dfef9d4973..3c7b2d78e6 100644 --- a/apps/concierge/src/app/reports/reports-menu.component.ts +++ b/apps/concierge/src/app/reports/reports-menu.component.ts @@ -94,6 +94,22 @@ const DEFAULT_FEATURES = [ } + @if (features().includes('user')) { + + person_search +

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

+
+

View Report

+ chevron_right +
+
+ } @for (report of custom_reports(); track report) { + + + + + +
+ + + +
+
+ +
+
+

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

+ @if (options().user; as user) { +

+ {{ user.name }} ({{ user.email }}) +

+

+ {{ options().start | date: 'mediumDate' }} – + {{ options().end | date: 'mediumDate' }} +

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

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

+
+ } @else if (!options().user) { +
+

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

+
+ } @else { +
+ @for (total of totals(); track total.name) { +
+

+ {{ total.name | translate }} +

+

{{ total.count }}

+
+ } +
+
+ + +
+ {{ + row.date + | date + : (row.all_day + ? 'mediumDate' + : 'MMM d, y, ' + time_format) + }} +
+
+ +
+ {{ + row.all_day + ? ('COMMON.ALL_DAY' | translate) + : (row.duration | duration: true) + }} +
+
+
+ } +
+ `, + styles: [ + ` + :host { + display: flex; + flex-direction: column; + height: 100%; + } + + button[icon][disabled] { + background-color: var(--neutral) !important; + } + `, + ], + providers: [UserReportService], + imports: [ + DatePipe, + FormsModule, + MatRippleModule, + MatProgressSpinnerModule, + MatTooltipModule, + AuthenticatedImageDirective, + DurationPipe, + IconComponent, + SimpleTableComponent, + TranslatePipe, + DateRangeFieldComponent, + UserSearchFieldComponent, + ], +}) +export class UserReportComponent { + private _state = inject(UserReportService); + private _settings = inject(SettingsService); + private _org = inject(OrganisationService); + + public readonly printing = signal(false); + public readonly options = this._state.options; + public readonly loading = this._state.loading; + public readonly rows = this._state.rows; + + public readonly totals = computed(() => { + const counts = this._state.counts(); + return [ + { + name: 'APP.CONCIERGE.REPORTS_TOTAL_BOOKINGS', + count: this.rows().length, + }, + { name: 'RESOURCE.DESKS', count: counts.desk }, + { name: 'RESOURCE.PARKING', count: counts.parking }, + { name: 'RESOURCE.LOCKERS', count: counts.locker }, + { name: 'RESOURCE.ROOMS', count: counts.room }, + ]; + }); + + public readonly logo = computed(() => { + this._org.active_building(); + return ( + (this._settings.theme === 'dark' + ? this._settings.get('app.logo_dark') + : this._settings.get('app.logo_light')) || {} + ); + }); + + public readonly setOptions = (options: Partial) => + this._state.setOptions(options); + public readonly downloadReport = () => this._state.downloadReport(); + + public readonly setStartDate = (date: number | Date) => + this._state.setOptions({ start: startOfDay(date).valueOf() }); + + public readonly setEndDate = (date: number | Date) => + this._state.setOptions({ end: endOfDay(date).valueOf() }); + + public get week_start() { + return this._settings.get('app.week_start'); + } + + public get time_format() { + return this._settings.time_format; + } + + public print() { + this.printing.set(true); + setTimeout(() => { + window.print(); + this.printing.set(false); + }, 300); + } +} diff --git a/apps/concierge/src/app/reports/user/user-report.service.ts b/apps/concierge/src/app/reports/user/user-report.service.ts new file mode 100644 index 0000000000..69afbad0f4 --- /dev/null +++ b/apps/concierge/src/app/reports/user/user-report.service.ts @@ -0,0 +1,216 @@ +import { computed, inject, Injectable, resource, signal } from '@angular/core'; +import { queryAllBookings } from '@placeos/bookings'; +import { + Booking, + CalendarEvent, + downloadFile, + i18n, + jsonToCsv, + OrganisationService, + SettingsService, + User, +} from '@placeos/common'; +import { queryAllEvents } from '@placeos/events'; +import { endOfDay, format, getUnixTime, startOfDay } from 'date-fns'; +import { reportBookingStatus, reportEventStatus } from '../reports.utilities'; + +export interface UserReportOptions { + /** Start of the report period in ms */ + start: number; + /** End of the report period in ms */ + end: number; + /** User to list the bookings of */ + user: User | null; +} + +export type UserReportType = 'desk' | 'parking' | 'locker' | 'room'; + +export interface UserReportRow { + type: UserReportType; + type_name: string; + name: string; + location: string; + date: number; + duration: number; + all_day: boolean; + checked_in: string; + status: string; +} + +/** Locale key for the name of each booking type in the report */ +const TYPE_NAMES: Record = { + desk: 'RESOURCE.DESK', + parking: 'RESOURCE.PARKING', + locker: 'RESOURCE.LOCKER', + room: 'RESOURCE.ROOM', +}; + +/** Booking types loaded from the bookings API */ +const BOOKING_TYPES = ['desk', 'parking', 'locker'] as const; + +/** + * Loads the desk, parking, locker and room bookings of a single user in the + * active building, or in the active region when `app.use_region` is set. + * Provide it on the report component, so loading stops when the user leaves + * the report. + */ +@Injectable() +export class UserReportService { + private _org = inject(OrganisationService); + private _settings = inject(SettingsService); + + private _options = signal({ + start: startOfDay(Date.now()).valueOf(), + end: endOfDay(Date.now()).valueOf(), + user: null, + }); + + /** + * Values that select the bookings to load. Compared by value, because + * the settings change after each building change and the resource loads + * again for each new params object. + */ + private readonly _params = computed( + () => { + const { start, end, user } = this._options(); + const zone = this._settings.get('app.use_region') + ? this._org.active_region()?.id + : this._org.active_building()?.id; + return { start, end, email: user?.email, zone }; + }, + { + equal: (a, b) => + a.start === b.start && + a.end === b.end && + a.email === b.email && + a.zone === b.zone, + }, + ); + + /** Loads the bookings when the user, the dates or the zone change */ + private readonly _rows = resource({ + params: () => this._params(), + defaultValue: [] as UserReportRow[], + loader: async ({ params: { start, end, email, zone } }) => { + if (!email || !zone) return []; + const period = { + period_start: getUnixTime(startOfDay(start)), + period_end: getUnixTime(endOfDay(end)), + }; + // The events API reads the calendar of another user only on + // some tenants. Load the room events of the zone, as the rooms + // report does, and keep the events that the user hosts. When + // the tenant forces the calendar host, `host_override` has the + // user who made the booking. + const [events, ...booking_lists] = await Promise.all([ + queryAllEvents({ + ...period, + zone_ids: zone, + include_cancelled: true, + limit: 200, + }), + ...BOOKING_TYPES.map((type) => + queryAllBookings({ + ...period, + type, + zones: zone, + email, + include_deleted: true, + include_checked_out: true, + limit: 200, + }), + ), + ]); + const host = email.toLowerCase(); + return [ + ...booking_lists.flat().map((_) => this._bookingRow(_)), + ...events + .filter((event) => + [event.extension_data?.host_override, event.host].some( + (_) => _?.toLowerCase() === host, + ), + ) + .map((_) => this._eventRow(_)), + ].sort((a, b) => a.date - b.date); + }, + }); + + public readonly options = this._options.asReadonly(); + public readonly loading = this._rows.isLoading; + public readonly rows = this._rows.value; + + /** Number of bookings for each type */ + public readonly counts = computed(() => { + const counts: Record = { + desk: 0, + parking: 0, + locker: 0, + room: 0, + }; + for (const row of this.rows()) counts[row.type] += 1; + return counts; + }); + + public setOptions(options: Partial) { + this._options.update((current) => ({ ...current, ...options })); + } + + public downloadReport() { + const { start, end, user } = this._options(); + const rows = this.rows(); + if (!user || !rows.length) return; + downloadFile( + `report+user+${user.email}+${format(start, 'yyyy-MM-dd')}+${format( + end, + 'yyyy-MM-dd', + )}.csv`, + jsonToCsv( + rows.map((row) => ({ + type: row.type_name, + name: row.name, + location: row.location, + date: format(row.date, 'yyyy-MM-dd HH:mm'), + duration: row.duration, + all_day: row.all_day, + checked_in: row.checked_in, + status: row.status, + })), + ), + ); + } + + private _bookingRow(booking: Booking): UserReportRow { + const type = booking.booking_type as UserReportType; + return { + type, + type_name: i18n(TYPE_NAMES[type]), + name: + booking.asset_name || + booking.extension_data?.asset_name || + booking.description || + booking.asset_id, + location: this._org.locationWithID(booking.zones).label, + date: booking.date, + duration: booking.duration, + all_day: booking.is_all_day, + checked_in: i18n(booking.checked_in ? 'COMMON.YES' : 'COMMON.NO'), + status: reportBookingStatus(booking), + }; + } + + private _eventRow(event: CalendarEvent): UserReportRow { + return { + type: 'room', + type_name: i18n(TYPE_NAMES.room), + name: event.system?.display_name || event.system?.name || '', + location: this._org.locationWithID([...(event.system?.zones || [])]) + .label, + date: event.date, + duration: event.duration, + all_day: event.is_all_day, + // Room events have no check-in state + checked_in: '', + status: reportEventStatus(event), + }; + } +} diff --git a/apps/concierge/src/app/ui/app-settings/concierge-settings-form-modal.component.ts b/apps/concierge/src/app/ui/app-settings/concierge-settings-form-modal.component.ts index a6dd252c51..2c1461f4a8 100644 --- a/apps/concierge/src/app/ui/app-settings/concierge-settings-form-modal.component.ts +++ b/apps/concierge/src/app/ui/app-settings/concierge-settings-form-modal.component.ts @@ -180,6 +180,9 @@ import { UploadButtonComponent } from './upload-button.component'; Visitors Report + + User Report + } diff --git a/apps/concierge/src/app/ui/app-sidebar.component.ts b/apps/concierge/src/app/ui/app-sidebar.component.ts index 2ea9cecfb5..12e87d5d6a 100644 --- a/apps/concierge/src/app/ui/app-sidebar.component.ts +++ b/apps/concierge/src/app/ui/app-sidebar.component.ts @@ -353,6 +353,11 @@ export class ApplicationSidebarComponent name: 'APP.CONCIERGE.MENU_REPORT_VISITORS', route: ['/reports/visitors'], }, + { + id: 'user-report', + name: 'APP.CONCIERGE.MENU_REPORT_USER', + route: ['/reports/user'], + }, ], }, ]; diff --git a/apps/concierge/src/environments/settings.schema.json b/apps/concierge/src/environments/settings.schema.json index abe4c534ca..468c79f32e 100644 --- a/apps/concierge/src/environments/settings.schema.json +++ b/apps/concierge/src/environments/settings.schema.json @@ -62,7 +62,7 @@ }, "features": { "type": "array", - "description": "List of features enabled in the app. e.g. `spaces`, `zones`, `desks`, `parking`, `visitors`, `assets`, `facilities`, `internal-users`, `points`, `surveys`, `catering`, `events`, `booking-report`, `attendance-report`, `contact-tracing-report`, `signage`, `email-templates`", + "description": "List of features enabled in the app. e.g. `spaces`, `zones`, `desks`, `parking`, `visitors`, `assets`, `facilities`, `internal-users`, `points`, `surveys`, `catering`, `events`, `booking-report`, `attendance-report`, `contact-tracing-report`, `user-report`, `signage`, `email-templates`", "items": { "type": "string" } }, "feature_groups": { @@ -528,7 +528,7 @@ "properties": { "features": { "type": "array", - "description": "List of report types shown in the reports menu. Defaults to `attendance`, `desks`, `spaces`, `catering` and `contact-tracing`", + "description": "List of report types shown in the reports menu. Defaults to `attendance`, `desks`, `spaces`, `catering` and `contact-tracing`. Add `user` to show the report of the bookings of a single user", "items": { "type": "string", "enum": [ @@ -536,14 +536,16 @@ "desks", "spaces", "catering", - "contact-tracing" + "contact-tracing", + "user" ], "enumNames": [ "Attendance", "Desks", "Spaces", "Catering", - "Contact tracing" + "Contact tracing", + "User bookings" ] } }, diff --git a/apps/concierge/src/tests/reports/user/user-report.service.spec.ts b/apps/concierge/src/tests/reports/user/user-report.service.spec.ts new file mode 100644 index 0000000000..a754269f4d --- /dev/null +++ b/apps/concierge/src/tests/reports/user/user-report.service.spec.ts @@ -0,0 +1,231 @@ +import { ApplicationRef, signal, WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + createServiceFactory, + SpectatorService, +} from '@ngneat/spectator/vitest'; +import { + Booking, + CalendarEvent, + OrganisationService, + SettingsService, + User, +} from '@placeos/common'; +import { MockProvider } from 'ng-mocks'; + +import * as ts_client_mod from '@placeos/ts-client'; +import { UserReportService } from 'apps/concierge/src/app/reports/user/user-report.service'; + +import { captureDownloads } from '../download-capture.helper'; + +vi.mock('@placeos/ts-client', { spy: true }); + +const start = new Date('2026-04-06T00:00:00').valueOf(); +const end = new Date('2026-04-07T23:59:59').valueOf(); +const user = new User({ name: 'Jane Doe', email: 'Jane.Doe@place.tech' }); +// Live events have the zones on the system, not on the room attendee +const room = { + id: 'sys-1', + email: 'room-1@place.tech', + name: 'Room 1', + zones: ['bld-1', 'lvl-1'], +}; +const room_attendee = { email: room.email, name: room.name, resource: true }; + +describe('UserReportService', () => { + let spectator: SpectatorService; + let bookings: Partial[]; + let events: CalendarEvent[]; + let downloads: ReturnType; + let use_region: boolean; + /** Stands in for the settings signals that `SettingsService.get` reads */ + let settings_change: WritableSignal; + let active_building: WritableSignal<{ id: string }>; + + const createService = createServiceFactory({ + service: UserReportService, + providers: [ + MockProvider(SettingsService, { + get: vi.fn(() => { + settings_change(); + return use_region; + }), + } as any), + ], + }); + + async function settle() { + await TestBed.inject(ApplicationRef).whenStable(); + } + + function queries() { + return vi + .mocked(ts_client_mod.query) + .mock.calls.map(([request]) => request.query_params); + } + + beforeEach(() => { + vi.clearAllMocks(); + use_region = false; + // New signals for each test, so a change does not reach the + // service of an earlier test + active_building = signal({ id: 'bld-1' }); + settings_change = signal(0); + bookings = []; + events = []; + downloads = captureDownloads(); + // queryAllBookings (query_params.type) / queryAllEvents (no type), + // both routed through the ts-client `query` seam one layer down. + vi.mocked(ts_client_mod.query).mockImplementation((req: any) => { + const { type } = req?.query_params || {}; + const data = type + ? bookings.filter((_) => _.booking_type === type) + : events; + return Promise.resolve({ data, next: undefined }) as any; + }); + spectator = createService({ + providers: [ + MockProvider(OrganisationService, { + active_building, + active_region: signal({ id: 'region-1' }), + locationWithID: vi.fn((zones: string[]) => ({ + label: (zones || []).join(' / '), + })), + } as any), + ], + }); + }); + + afterEach(() => { + downloads.restore(); + }); + + it('should not request bookings before a user is selected', async () => { + spectator.service.setOptions({ start, end }); + await settle(); + expect(ts_client_mod.query).not.toHaveBeenCalled(); + expect(spectator.service.rows()).toEqual([]); + }); + + it('should request each booking type by email in the active building', async () => { + spectator.service.setOptions({ start, end, user }); + await settle(); + const booking_queries = queries().filter((_) => _.type); + expect(booking_queries.map((_) => _.type).sort()).toEqual([ + 'desk', + 'locker', + 'parking', + ]); + for (const query of booking_queries) { + expect(query.email).toBe(user.email); + expect(query.zones).toBe('bld-1'); + expect(query.include_deleted).toBe(true); + } + const event_query = queries().find((_) => !_.type); + expect(event_query.zone_ids).toBe('bld-1'); + }); + + it('should use the active region when regions are in use', async () => { + use_region = true; + spectator.service.setOptions({ start, end, user }); + await settle(); + const zones = queries().map((_) => _.zones || _.zone_ids); + expect(zones).toEqual(Array(4).fill('region-1')); + }); + + it('should not load again when settings change but the zone does not', async () => { + spectator.service.setOptions({ start, end, user }); + await settle(); + settings_change.set(1); + await settle(); + expect(queries().length).toBe(4); + }); + + it('should load again when the active building changes', async () => { + spectator.service.setOptions({ start, end, user }); + await settle(); + active_building.set({ id: 'bld-2' }); + await settle(); + expect(queries().at(-1).zones).toBe('bld-2'); + }); + + it('should list bookings and hosted room events by date', async () => { + bookings = [ + new Booking({ + booking_type: 'parking', + asset_name: 'Bay 4', + date: start + 3 * 60 * 60 * 1000, + zones: ['bld-1', 'lvl-p'], + checked_in: true, + approved: true, + }), + new Booking({ + booking_type: 'desk', + asset_name: 'Desk 12', + date: start + 60 * 60 * 1000, + zones: ['bld-1', 'lvl-1'], + deleted: true, + }), + ]; + events = [ + // Host email differs only in case from the selected user + new CalendarEvent({ + host: 'jane.doe@place.tech', + date: start + 2 * 60 * 60 * 1000, + attendees: [room_attendee as any], + system: room as any, + }), + // The tenant forces the room as the calendar host + new CalendarEvent({ + host: room.email, + date: start + 4 * 60 * 60 * 1000, + attendees: [room_attendee as any], + system: room as any, + extension_data: { host_override: 'jane.doe@place.tech' }, + } as any), + // Room event of another host + new CalendarEvent({ + host: 'other@place.tech', + date: start, + attendees: [room_attendee as any], + system: room as any, + }), + ]; + spectator.service.setOptions({ start, end, user }); + await settle(); + const rows = spectator.service.rows(); + expect(rows.map((_) => [_.type, _.name, _.location])).toEqual([ + ['desk', 'Desk 12', 'bld-1 / lvl-1'], + ['room', 'Room 1', 'bld-1 / lvl-1'], + ['parking', 'Bay 4', 'bld-1 / lvl-p'], + ['room', 'Room 1', 'bld-1 / lvl-1'], + ]); + expect(rows[0].status).toBe('Cancelled'); + expect(spectator.service.counts()).toEqual({ + desk: 1, + parking: 1, + locker: 0, + room: 2, + }); + }); + + it('should download the listed bookings as CSV', async () => { + bookings = [ + new Booking({ + booking_type: 'locker', + asset_name: 'Locker 7', + date: start + 60 * 60 * 1000, + zones: ['lvl-1'], + }), + ]; + spectator.service.setOptions({ start, end, user }); + await settle(); + spectator.service.downloadReport(); + expect(downloads.filename).toBe( + `report+user+${user.email}+2026-04-06+2026-04-07.csv`, + ); + const text = await downloads.text(); + expect(text).toContain('Locker 7'); + expect(text).toContain('2026-04-06 01:00'); + }); +}); diff --git a/docs/settings/concierge.md b/docs/settings/concierge.md index 0baa5fbc5f..1bc8c8de9a 100644 --- a/docs/settings/concierge.md +++ b/docs/settings/concierge.md @@ -54,7 +54,7 @@ Each locale entry requires an `id` and `name`: | Setting | Type | Default | Description | |---|---|---|---| -| `features` | string[] | All features enabled (see below) | List of features enabled in the app. Available values: `spaces`, `zones`, `desks`, `parking`, `visitors`, `assets`, `facilities`, `internal-users`, `points`, `surveys`, `catering`, `events`, `booking-report`, `attendance-report`, `contact-tracing-report`, `signage`, `email-templates`. | +| `features` | string[] | All features enabled (see below) | List of features enabled in the app. Available values: `spaces`, `zones`, `desks`, `parking`, `visitors`, `assets`, `facilities`, `internal-users`, `points`, `surveys`, `catering`, `events`, `booking-report`, `attendance-report`, `contact-tracing-report`, `user-report`, `signage`, `email-templates`. `user-report` is off by default. It adds a report of the desk, parking, locker and room bookings of a single user in the active building or region. | | `feature_groups` | object | – | Maps feature names to the user groups allowed to access them, e.g. `parking-requests`. Features without an entry are available to all users. | | `admin_group` | string | `"concierge_admins"` | Name of the user group that grants access to admin sections of the app. Members of `placeos_admin` and `placeos_support` are always allowed. | | `delegated` | boolean | `false` | Set when your PlaceOS instance uses delegated calendar access. Hides event actions from the event details view. | @@ -237,7 +237,7 @@ Each custom column requires a `field` (the booking extension data field to displ | Setting | Type | Default | Description | |---|---|---|---| -| `reports.features` | string[] | – | List of report types shown in the reports menu. Defaults to `attendance`, `desks`, `spaces`, `catering` and `contact-tracing`. | +| `reports.features` | string[] | – | List of report types shown in the reports menu. Defaults to `attendance`, `desks`, `spaces`, `catering` and `contact-tracing`. Add `user` to show the report of the bookings of a single user. | | `reports.attendance_include_weekends` | boolean | `false` | Include weekends in attendance report calculations. | | `reports.ignore_days` | array | – | Days of the week to exclude from report calculations. Either day names (e.g. `"saturday"`) or day indexes (0-6). | | `custom_reports` | array | `[]` | Custom report pages to add to the reports section. Each report is rendered as an embedded page. | diff --git a/shared/assets/locale/ar.json b/shared/assets/locale/ar.json index 39e01eb81e..3a7f600282 100644 --- a/shared/assets/locale/ar.json +++ b/shared/assets/locale/ar.json @@ -917,6 +917,7 @@ "MENU_REPORT_CONTACT_TRACING": "تتبع المخالطين", "MENU_REPORT_ASSETS": "طلبات الأصول", "MENU_REPORT_VISITORS": "حجوزات الزوار", + "MENU_REPORT_USER": "حجوزات المستخدم", "TIMEZONE_DIFF": "يتم عرض المنطقة الزمنية للمبنى وهي مختلفة عن منطقتك الزمنية المحلية.", "ROOM_BOOKINGS": "حجوزات الغرف", "SETUP_BREAKDOWN": "إعداد / تفكيك", @@ -1757,6 +1758,8 @@ "REPORTS_CONTACTS_HEADER": "تقرير تتبع المخالطين", "REPORTS_ASSETS_HEADER": "تقرير الأصول", "REPORTS_VISITORS_HEADER": "تقرير الزوار", + "REPORTS_USER_HEADER": "تقرير حجوزات المستخدم", + "REPORTS_USER_EMPTY": "حدد مستخدمًا ونطاقًا زمنيًا لإنشاء التقرير", "REPORTS_LOAD_ERROR": "لم يتم العثور على حجوزات للمناطق ونطاق التاريخ المحددين", "REPORTS_CATERING_ITEMS_HEADER": "العناصر المطلوبة", "REPORTS_CATERING_ITEMS_UNIQUE": "العناصر الفريدة", diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 371dfecb58..448f7abc11 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -2100,6 +2100,7 @@ "MENU_REPORT_CONTACT_TRACING": "Contact Tracing", "MENU_REPORT_ASSETS": "Asset Requests", "MENU_REPORT_VISITORS": "Visitor Bookings", + "MENU_REPORT_USER": "User Bookings", "TIMEZONE_DIFF": "Timezone of the building is displayed and is different from your local timezone.", "ROOM_BOOKINGS": "Room Bookings", "SETUP_BREAKDOWN": "Setup & Breakdown", @@ -2994,6 +2995,8 @@ "REPORTS_CONTACTS_HEADER": "Contact Tracing Report", "REPORTS_ASSETS_HEADER": "Assets Report", "REPORTS_VISITORS_HEADER": "Visitors Report", + "REPORTS_USER_HEADER": "User Bookings Report", + "REPORTS_USER_EMPTY": "Select a user and date range to generate the report", "REPORTS_SITE_ATTENDANCE_HEADER": "Site Attendance Report", "REPORTS_HOSTS_HEADER": "Hosts", "REPORTS_LOAD_ERROR": "No bookings found for the selected zones and date range", diff --git a/shared/assets/locale/en-GB.json b/shared/assets/locale/en-GB.json index 56c21625c9..33ab9ffda9 100644 --- a/shared/assets/locale/en-GB.json +++ b/shared/assets/locale/en-GB.json @@ -958,6 +958,7 @@ "MENU_REPORT_CONTACT_TRACING": "Contact Tracing", "MENU_REPORT_ASSETS": "Asset Requests", "MENU_REPORT_VISITORS": "Visitor Bookings", + "MENU_REPORT_USER": "User Bookings", "TIMEZONE_DIFF": "Timezone of the building is displayed and is different from your local timezone.", "ROOM_BOOKINGS": "Room Bookings", "SETUP_BREAKDOWN": "Setup & Breakdown", @@ -1840,6 +1841,8 @@ "REPORTS_CONTACTS_HEADER": "Contact Tracing Report", "REPORTS_ASSETS_HEADER": "Assets Report", "REPORTS_VISITORS_HEADER": "Visitors Report", + "REPORTS_USER_HEADER": "User Bookings Report", + "REPORTS_USER_EMPTY": "Select a user and date range to generate the report", "REPORTS_SITE_ATTENDANCE_HEADER": "Site Attendance Report", "REPORTS_HOSTS_HEADER": "Hosts", "REPORTS_LOAD_ERROR": "No bookings found for the selected zones and date range", diff --git a/shared/assets/locale/en-US.json b/shared/assets/locale/en-US.json index d2c091842e..b86e5b9fda 100644 --- a/shared/assets/locale/en-US.json +++ b/shared/assets/locale/en-US.json @@ -958,6 +958,7 @@ "MENU_REPORT_CONTACT_TRACING": "Contact Tracing", "MENU_REPORT_ASSETS": "Asset Requests", "MENU_REPORT_VISITORS": "Visitor Bookings", + "MENU_REPORT_USER": "User Bookings", "TIMEZONE_DIFF": "Timezone of the building is displayed and is different from your local timezone.", "ROOM_BOOKINGS": "Room Bookings", "SETUP_BREAKDOWN": "Setup & Breakdown", @@ -1842,6 +1843,8 @@ "REPORTS_CONTACTS_HEADER": "Contact Tracing Report", "REPORTS_ASSETS_HEADER": "Assets Report", "REPORTS_VISITORS_HEADER": "Visitors Report", + "REPORTS_USER_HEADER": "User Bookings Report", + "REPORTS_USER_EMPTY": "Select a user and date range to generate the report", "REPORTS_SITE_ATTENDANCE_HEADER": "Site Attendance Report", "REPORTS_HOSTS_HEADER": "Hosts", "REPORTS_LOAD_ERROR": "No bookings found for the selected zones and date range", diff --git a/shared/assets/locale/es.json b/shared/assets/locale/es.json index 2d1aab3867..375f89e6fd 100644 --- a/shared/assets/locale/es.json +++ b/shared/assets/locale/es.json @@ -917,6 +917,7 @@ "MENU_REPORT_CONTACT_TRACING": "Rastreo de Contactos", "MENU_REPORT_ASSETS": "Solicitudes de Activos", "MENU_REPORT_VISITORS": "Reservas de Visitantes", + "MENU_REPORT_USER": "Reservas de Usuario", "TIMEZONE_DIFF": "Se muestra la zona horaria del edificio y es diferente de su zona horaria local.", "ROOM_BOOKINGS": "Reservas de Salas", "SETUP_BREAKDOWN": "Preparación / Desmontaje", @@ -1757,6 +1758,8 @@ "REPORTS_CONTACTS_HEADER": "Informe de Rastreo de Contactos", "REPORTS_ASSETS_HEADER": "Informe de Activos", "REPORTS_VISITORS_HEADER": "Informe de Visitantes", + "REPORTS_USER_HEADER": "Informe de Reservas de Usuario", + "REPORTS_USER_EMPTY": "Seleccione un usuario y un rango de fechas para generar el informe", "REPORTS_LOAD_ERROR": "No se encontraron reservas para las zonas y el rango de fechas seleccionados", "REPORTS_CATERING_ITEMS_HEADER": "Artículos Pedidos", "REPORTS_CATERING_ITEMS_UNIQUE": "Artículos Únicos", diff --git a/shared/assets/locale/fr-CA.json b/shared/assets/locale/fr-CA.json index b6170f49f9..7adf05748e 100644 --- a/shared/assets/locale/fr-CA.json +++ b/shared/assets/locale/fr-CA.json @@ -917,6 +917,7 @@ "MENU_REPORT_CONTACT_TRACING": "Traçage des contacts", "MENU_REPORT_ASSETS": "Demandes d'actifs", "MENU_REPORT_VISITORS": "Réservations de visiteurs", + "MENU_REPORT_USER": "Réservations d'un utilisateur", "TIMEZONE_DIFF": "Le fuseau horaire de l'immeuble est affiché et est différent de votre fuseau horaire local.", "ROOM_BOOKINGS": "Réservations de salles", "SETUP_BREAKDOWN": "Préparation / Rangement", @@ -1757,6 +1758,8 @@ "REPORTS_CONTACTS_HEADER": "Rapport de traçage des contacts", "REPORTS_ASSETS_HEADER": "Rapport sur les actifs", "REPORTS_VISITORS_HEADER": "Rapport sur les visiteurs", + "REPORTS_USER_HEADER": "Rapport sur les réservations d'un utilisateur", + "REPORTS_USER_EMPTY": "Sélectionnez un utilisateur et une plage de dates pour générer le rapport", "REPORTS_LOAD_ERROR": "Aucune réservation trouvée pour les zones et la plage de dates sélectionnées", "REPORTS_CATERING_ITEMS_HEADER": "Articles commandés", "REPORTS_CATERING_ITEMS_UNIQUE": "Articles uniques", diff --git a/shared/assets/locale/fr.json b/shared/assets/locale/fr.json index db2856a420..5f46e52f70 100644 --- a/shared/assets/locale/fr.json +++ b/shared/assets/locale/fr.json @@ -917,6 +917,7 @@ "MENU_REPORT_CONTACT_TRACING": "Suivi des contacts", "MENU_REPORT_ASSETS": "Demandes de ressources", "MENU_REPORT_VISITORS": "Réservations de visiteurs", + "MENU_REPORT_USER": "Réservations d'un utilisateur", "TIMEZONE_DIFF": "Le fuseau horaire du bâtiment est affiché et diffère de votre fuseau horaire local.", "ROOM_BOOKINGS": "Réservations de salles", "SETUP_BREAKDOWN": "Installation / Démontage", @@ -1757,6 +1758,8 @@ "REPORTS_CONTACTS_HEADER": "Rapport de traçage des contacts", "REPORTS_ASSETS_HEADER": "Rapport de ressources", "REPORTS_VISITORS_HEADER": "Rapport de visiteurs", + "REPORTS_USER_HEADER": "Rapport des réservations d'un utilisateur", + "REPORTS_USER_EMPTY": "Sélectionnez un utilisateur et une plage de dates pour générer le rapport", "REPORTS_LOAD_ERROR": "Aucune réservation trouvée pour les zones et la plage de dates sélectionnées", "REPORTS_CATERING_ITEMS_HEADER": "Articles commandés", "REPORTS_CATERING_ITEMS_UNIQUE": "Articles uniques", diff --git a/shared/assets/locale/it.json b/shared/assets/locale/it.json index 9430547c74..351b9fc210 100644 --- a/shared/assets/locale/it.json +++ b/shared/assets/locale/it.json @@ -917,6 +917,7 @@ "MENU_REPORT_CONTACT_TRACING": "Tracciamento contatti", "MENU_REPORT_ASSETS": "Richieste di risorse", "MENU_REPORT_VISITORS": "Prenotazioni visitatori", + "MENU_REPORT_USER": "Prenotazioni utente", "TIMEZONE_DIFF": "Il fuso orario dell'edificio è mostrato ed è diverso dal tuo fuso orario locale.", "ROOM_BOOKINGS": "Prenotazioni di stanze", "SETUP_BREAKDOWN": "Preparazione / Smontaggio", @@ -1757,6 +1758,8 @@ "REPORTS_CONTACTS_HEADER": "Report tracciamento contatti", "REPORTS_ASSETS_HEADER": "Report risorse", "REPORTS_VISITORS_HEADER": "Report visitatori", + "REPORTS_USER_HEADER": "Report prenotazioni utente", + "REPORTS_USER_EMPTY": "Seleziona un utente e un intervallo di date per generare il report", "REPORTS_LOAD_ERROR": "Nessuna prenotazione trovata per le zone selezionate e l'intervallo di date", "REPORTS_CATERING_ITEMS_HEADER": "Elementi ordinati", "REPORTS_CATERING_ITEMS_UNIQUE": "Elementi unici", diff --git a/shared/assets/locale/ja-JP.json b/shared/assets/locale/ja-JP.json index 914738e726..6bb0790dbe 100644 --- a/shared/assets/locale/ja-JP.json +++ b/shared/assets/locale/ja-JP.json @@ -917,6 +917,7 @@ "MENU_REPORT_CONTACT_TRACING": "接触者追跡", "MENU_REPORT_ASSETS": "資産リクエスト", "MENU_REPORT_VISITORS": "訪問者予約", + "MENU_REPORT_USER": "ユーザー予約", "TIMEZONE_DIFF": "建物のタイムゾーンが表示されており、あなたのローカルタイムゾーンとは異なります。", "ROOM_BOOKINGS": "会議室予約", "SETUP_BREAKDOWN": "準備 / 片付け", @@ -1757,6 +1758,8 @@ "REPORTS_CONTACTS_HEADER": "接触者追跡レポート", "REPORTS_ASSETS_HEADER": "資産レポート", "REPORTS_VISITORS_HEADER": "訪問者レポート", + "REPORTS_USER_HEADER": "ユーザー予約レポート", + "REPORTS_USER_EMPTY": "レポートを生成するには、ユーザーと期間を選択してください", "REPORTS_LOAD_ERROR": "選択されたゾーンと日付範囲に予約が見つかりませんでした", "REPORTS_CATERING_ITEMS_HEADER": "注文済みアイテム", "REPORTS_CATERING_ITEMS_UNIQUE": "ユニークアイテム", diff --git a/shared/assets/locale/pt.json b/shared/assets/locale/pt.json index 9adb182bce..352327f73b 100644 --- a/shared/assets/locale/pt.json +++ b/shared/assets/locale/pt.json @@ -917,6 +917,7 @@ "MENU_REPORT_CONTACT_TRACING": "Rastreamento de Contatos", "MENU_REPORT_ASSETS": "Solicitações de Ativos", "MENU_REPORT_VISITORS": "Reservas de Visitantes", + "MENU_REPORT_USER": "Reservas de Usuário", "TIMEZONE_DIFF": "O fuso horário do prédio é exibido e é diferente do seu fuso horário local.", "ROOM_BOOKINGS": "Reservas de Sala", "SETUP_BREAKDOWN": "Configuração / Desmontagem", @@ -1757,6 +1758,8 @@ "REPORTS_CONTACTS_HEADER": "Relatório de Rastreamento de Contatos", "REPORTS_ASSETS_HEADER": "Relatório de Ativos", "REPORTS_VISITORS_HEADER": "Relatório de Visitantes", + "REPORTS_USER_HEADER": "Relatório de Reservas de Usuário", + "REPORTS_USER_EMPTY": "Selecione um usuário e um intervalo de datas para gerar o relatório", "REPORTS_LOAD_ERROR": "Nenhuma reserva encontrada para as zonas e o intervalo de datas selecionados", "REPORTS_CATERING_ITEMS_HEADER": "Itens Solicitados", "REPORTS_CATERING_ITEMS_UNIQUE": "Itens Únicos", diff --git a/shared/assets/locale/zh.json b/shared/assets/locale/zh.json index 49864069f7..4ffa7519ec 100644 --- a/shared/assets/locale/zh.json +++ b/shared/assets/locale/zh.json @@ -917,6 +917,7 @@ "MENU_REPORT_CONTACT_TRACING": "接触者追踪", "MENU_REPORT_ASSETS": "资产请求", "MENU_REPORT_VISITORS": "访客预订", + "MENU_REPORT_USER": "用户预订", "TIMEZONE_DIFF": "显示的是建筑所在时区,与您的本地时区不同。", "ROOM_BOOKINGS": "房间预订", "SETUP_BREAKDOWN": "准备/清理", @@ -1757,6 +1758,8 @@ "REPORTS_CONTACTS_HEADER": "接触者追踪报告", "REPORTS_ASSETS_HEADER": "资产报告", "REPORTS_VISITORS_HEADER": "访客报告", + "REPORTS_USER_HEADER": "用户预订报告", + "REPORTS_USER_EMPTY": "选择用户和日期范围以生成报告", "REPORTS_LOAD_ERROR": "未找到所选区域和日期范围的预订", "REPORTS_CATERING_ITEMS_HEADER": "已订购项目", "REPORTS_CATERING_ITEMS_UNIQUE": "唯一项目",