Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/concierge/src/app/reports/reports-menu.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,22 @@ const DEFAULT_FEATURES = [
</div>
</a>
}
@if (features().includes('user')) {
<a
[routerLink]="['/reports', 'user']"
matRipple
class="border-base-200 bg-base-100 flex h-64 w-64 flex-col items-center justify-center rounded-sm border p-4 shadow-sm hover:opacity-80"
>
<icon class="text-8xl">person_search</icon>
<h3 class="mb-4 text-center text-xl font-bold">
{{ 'APP.CONCIERGE.MENU_REPORT_USER' | translate }}
</h3>
<div class="flex items-center">
<p>View Report</p>
<icon class="ml-2">chevron_right</icon>
</div>
</a>
}
@for (report of custom_reports(); track report) {
<a
[routerLink]="['/reports', report.id]"
Expand Down
6 changes: 6 additions & 0 deletions apps/concierge/src/app/reports/reports.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ParkingReportComponent } from './parking/parking-report.component';
import { ReportsOptionsComponent } from './reports-options.component';
import { ReportsComponent } from './reports.component';
import { ReportSpacesComponent } from './spaces/report-spaces.component';
import { UserReportComponent } from './user/user-report.component';
import { VisitorsReportComponent } from './visitors/visitors-report.component';

const children: Route[] = [
Expand Down Expand Up @@ -55,6 +56,11 @@ const children: Route[] = [
component: VisitorsReportComponent,
title: 'Visitor Reports',
},
{
path: 'user',
component: UserReportComponent,
title: 'User Booking Reports',
},
{ path: ':id', component: CustomReportComponent, title: 'Reports' },
{ path: '**', redirectTo: 'desks', pathMatch: 'full' },
];
Expand Down
7 changes: 7 additions & 0 deletions apps/concierge/src/app/reports/reports.utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ export function reportBookingStatus(booking: Booking): string {
return booking.status || 'tentative';
}

export function reportEventStatus(event: CalendarEvent): string {
if (event.deleted) return 'Cancelled';
if (isDeclinedReportEvent(event)) return 'Rejected';
if (event.status === 'approved') return 'Approved';
return event.status || 'tentative';
}

export function formatReportPercentage(value = 0, total = 0): string {
const percent = total ? Math.floor((value / total) * 1000) / 10 : 0;
return `${value || 0} (${percent}%)`;
Expand Down
272 changes: 272 additions & 0 deletions apps/concierge/src/app/reports/user/user-report.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
import { DatePipe } from '@angular/common';
import { Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatRippleModule } from '@angular/material/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { OrganisationService, SettingsService } from '@placeos/common';
import {
AuthenticatedImageDirective,
DurationPipe,
IconComponent,
SimpleTableComponent,
TranslatePipe,
} from '@placeos/components';
import {
DateRangeFieldComponent,
UserSearchFieldComponent,
} from '@placeos/form-fields';
import { endOfDay, startOfDay } from 'date-fns';
import { UserReportOptions, UserReportService } from './user-report.service';

/**
* Report of the desk, parking, locker and room bookings of a single user in
* the active building or region.
*/
@Component({
selector: '[user-report]',
template: `
<div
class="border-base-200 bg-base-100 z-20 flex h-20 w-full items-center space-x-2 border-b p-4 print:hidden"
>
<a-user-search-field
class="w-64"
[ngModel]="options().user"
(ngModelChange)="setOptions({ user: $event || null })"
></a-user-search-field>
<date-range-field [week_start]="week_start" [from]="0">
<input
#startDate
[ngModel]="options().start"
(ngModelChange)="$event ? setStartDate($event) : ''"
/>
<input
#endDate
[ngModel]="options().end"
(ngModelChange)="$event ? setEndDate($event) : ''"
/>
</date-range-field>
<div class="flex-1"></div>
<button
icon
matRipple
class="bg-secondary text-secondary-content h-12 w-12 rounded-sm"
[disabled]="!rows().length"
[matTooltip]="'APP.CONCIERGE.REPORTS_DOWNLOAD' | translate"
(click)="downloadReport()"
>
<icon>download</icon>
</button>
<button
icon
matRipple
class="bg-secondary text-secondary-content h-12 w-12 rounded-sm"
[disabled]="!rows().length"
[matTooltip]="'APP.CONCIERGE.REPORTS_PRINT' | translate"
(click)="print()"
>
<icon>print</icon>
</button>
</div>
<div
class="relative h-1/2 w-full flex-1 overflow-auto print:h-auto print:overflow-visible"
>
<div class="bg-base-200 m-4 flex items-center rounded-sm p-4">
<img auth class="h-12" [source]="logo()?.src || logo()" />
<div class="flex-1"></div>
<div class="px-2 text-right">
<h2 class="text-2xl font-medium">
{{ 'APP.CONCIERGE.REPORTS_USER_HEADER' | translate }}
</h2>
@if (options().user; as user) {
<p class="text-sm opacity-60">
{{ user.name }} ({{ user.email }})
</p>
<p class="text-sm opacity-60">
{{ options().start | date: 'mediumDate' }} &ndash;
{{ options().end | date: 'mediumDate' }}
</p>
}
</div>
</div>
@if (loading()) {
<div class="flex h-full w-full flex-col items-center p-8">
<mat-spinner [diameter]="32" class="mb-4"></mat-spinner>
<p class="opacity-30">
{{ 'APP.CONCIERGE.REPORTS_LOADING' | translate }}
</p>
</div>
} @else if (!options().user) {
<div
class="screen-only flex h-full w-full flex-col items-center p-8"
>
<p class="opacity-30">
{{ 'APP.CONCIERGE.REPORTS_USER_EMPTY' | translate }}
</p>
</div>
} @else {
<div
class="border-base-200 bg-base-100 m-4 flex items-center justify-center space-x-2 rounded-sm border p-4"
>
@for (total of totals(); track total.name) {
<div class="flex flex-1 flex-col items-center">
<h3 class="text-sm">
{{ total.name | translate }}
</h3>
<p class="text-2xl">{{ total.count }}</p>
</div>
}
</div>
<div
class="border-base-200 bg-base-100 m-4 overflow-hidden rounded-sm border"
>
<simple-table
class="block w-full text-sm"
[data]="rows()"
[columns]="[
{
key: 'type_name',
name: 'COMMON.TYPE' | translate,
},
{ key: 'name', name: 'FORM.NAME' | translate },
{
key: 'location',
name: 'COMMON.LOCATION' | translate,
},
{
key: 'date',
name: 'FORM.DATE' | translate,
content: date_template,
},
{
key: 'duration',
name: 'FORM.DURATION' | translate,
content: duration_template,
},
{
key: 'checked_in',
name: 'COMMON.CHECKED_IN' | translate,
},
{
key: 'status',
name: 'COMMON.STATUS' | translate,
},
]"
[sortable]="true"
[page_size]="printing() ? 0 : 20"
[empty_message]="
'APP.CONCIERGE.REPORTS_DAILY_EMPTY' | translate
"
></simple-table>
<ng-template #date_template let-row="row">
<div class="p-4">
{{
row.date
| date
: (row.all_day
? 'mediumDate'
: 'MMM d, y, ' + time_format)
}}
</div>
</ng-template>
<ng-template #duration_template let-row="row">
<div class="p-4">
{{
row.all_day
? ('COMMON.ALL_DAY' | translate)
: (row.duration | duration: true)
}}
</div>
</ng-template>
</div>
}
</div>
`,
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<UserReportOptions>) =>
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);
}
}
Loading
Loading