src/app/features/reporting/reporting/reporting.component.ts
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | app-reporting |
| standalone | true |
| imports |
CustomDatePipe
MatIconButton
MatMenuTrigger
Angulartics2Module
MatMenu
MatExpansionModule
|
| styleUrls | ./reporting.component.scss |
| templateUrl | ./reporting.component.html |
No results matching.
ViewTitleComponent
SelectReportComponent
ReportRowComponent
ObjectTableComponent
CustomDatePipe
JsonPipe
SqlV2TableComponent
FaIconComponent
ViewActionsComponent
MatIconButton
MatMenuTrigger
Angulartics2Module
DisableEntityOperationDirective
MatMenu
MatMenuItem
MatExpansionModule
RouterLink
FeatureDisabledInfoComponent
Properties |
|
Methods |
| Async calculateResults | ||||||||||||
calculateResults(selectedReport: ReportEntity, fromDate: Date, toDate: Date)
|
||||||||||||
|
Parameters :
Returns :
Promise<void>
|
| onReportCriteriaChange |
onReportCriteriaChange()
|
|
Returns :
void
|
| currentReport |
Type : unknown
|
Default value : signal<ReportEntity | undefined>(undefined)
|
| data |
Type : unknown
|
Default value : signal<any[]>([])
|
| dateRangeOptions |
Type : unknown
|
Default value : signal<DateRangeFilterConfigOption[]>(
this.loadDateRangeOptionsFromConfig(),
)
|
| errorDetails |
Type : unknown
|
Default value : signal<string | null>(null)
|
| isError |
Type : unknown
|
Default value : signal(false)
|
| isHierarchicalReport |
Type : unknown
|
Default value : computed(() =>
isHierarchicalReport(this.currentReport()),
)
|
|
whether the current SQL report renders as a hierarchical group/count table |
| isLoading |
Type : unknown
|
Default value : signal(false)
|
| isRefreshing |
Type : unknown
|
Default value : signal(false)
|
|
Whether a background refresh of a stale calculation is currently running. |
| mode |
Type : unknown
|
Default value : computed<ReportEntity["mode"]>(
() => this.currentReport()?.mode ?? "reporting",
)
|
| Protected Readonly reportAdminLink |
Type : unknown
|
Default value : getEntityRuntimeRoute(ReportEntity)
|
|
runtime route to the report admin list (Admin Overview → Templates and Forms) |
| reportCalculation |
Type : unknown
|
Default value : signal<ReportCalculation | null>(null)
|
| Protected Readonly reportEntity |
Type : unknown
|
Default value : ReportEntity
|
|
entity type used to permission-gate the "Manage Reports" admin link |
| reports |
Type : unknown
|
Default value : this.reportsResource.value
|
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
resource,
signal,
} from "@angular/core";
import { DataAggregationService } from "../data-aggregation.service";
import {
getGroupingInformationString,
GroupByDescription,
} from "../report-row";
import moment from "moment";
import { JsonPipe } from "@angular/common";
import { CustomDatePipe } from "../../../core/basic-datatypes/date/custom-date.pipe";
import { ViewTitleComponent } from "../../../core/common-components/view-title/view-title.component";
import { SelectReportComponent } from "./select-report/select-report.component";
import { ReportRowComponent } from "./report-row/report-row.component";
import { ObjectTableComponent } from "./object-table/object-table.component";
import { DataTransformationService } from "../../../core/export/data-transformation-service/data-transformation.service";
import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service";
import {
isHierarchicalReport,
ReportEntity,
SqlReport,
} from "../report-config";
import {
ReportCalculation,
ReportCalculationError,
SqlReportService,
} from "../sql-report/sql-report.service";
import { RouteTarget } from "../../../route-target";
import { firstValueFrom } from "rxjs";
import { SqlV2TableComponent } from "./sql-v2-table/sql-v2-table.component";
import { ConfigService } from "app/core/config/config.service";
import {
DateRangeFilterConfig,
DateRangeFilterConfigOption,
} from "app/core/entity-list/EntityListConfig";
import { FaIconComponent } from "@fortawesome/angular-fontawesome";
import { ViewActionsComponent } from "#src/app/core/common-components/view-actions/view-actions.component";
import { MatIconButton } from "@angular/material/button";
import { MatMenu, MatMenuItem, MatMenuTrigger } from "@angular/material/menu";
import { Angulartics2Module } from "angulartics2";
import { DisableEntityOperationDirective } from "#src/app/core/permissions/permission-directive/disable-entity-operation.directive";
import { Logging } from "#src/app/core/logging/logging.service";
import { MatExpansionModule } from "@angular/material/expansion";
import { RouterLink } from "@angular/router";
import { getEntityRuntimeRoute } from "#src/app/core/entity/entity-config.service";
import { FeatureDisabledInfoComponent } from "#src/app/core/common-components/feature-disabled-info/feature-disabled-info.component";
/** A shown calculation older than this (seconds) is treated as stale/outdated. */
const STALE_THRESHOLD_SECONDS = 60; // 1 minute
@RouteTarget("Reporting")
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-reporting",
templateUrl: "./reporting.component.html",
styleUrls: ["./reporting.component.scss"],
imports: [
ViewTitleComponent,
SelectReportComponent,
ReportRowComponent,
ObjectTableComponent,
CustomDatePipe,
JsonPipe,
SqlV2TableComponent,
FaIconComponent,
ViewActionsComponent,
MatIconButton,
MatMenuTrigger,
Angulartics2Module,
DisableEntityOperationDirective,
MatMenu,
MatMenuItem,
MatExpansionModule,
RouterLink,
FeatureDisabledInfoComponent,
],
})
export class ReportingComponent {
private dataAggregationService = inject(DataAggregationService);
private dataTransformationService = inject(DataTransformationService);
private sqlReportService = inject(SqlReportService);
private entityMapper = inject(EntityMapperService);
private configService = inject(ConfigService);
/** runtime route to the report admin list (Admin Overview → Templates and Forms) */
protected readonly reportAdminLink = getEntityRuntimeRoute(ReportEntity);
/** entity type used to permission-gate the "Manage Reports" admin link */
protected readonly reportEntity = ReportEntity;
private reportsResource = resource({
loader: () =>
this.entityMapper
.loadType(ReportEntity)
.then((res) => res.sort((a, b) => a.title?.localeCompare(b.title))),
});
reports = this.reportsResource.value;
currentReport = signal<ReportEntity | undefined>(undefined);
mode = computed<ReportEntity["mode"]>(
() => this.currentReport()?.mode ?? "reporting",
);
/**
* Whether the server-side reporting backend required for "sql" reports is available;
* used to warn the user when they select an SQL report but the feature isn't enabled.
*/
readonly reportingBackendEnabled = resource({
loader: () => this.sqlReportService.isReportingBackendEnabled(),
});
/** whether the current SQL report renders as a hierarchical group/count table */
isHierarchicalReport = computed(() =>
isHierarchicalReport(this.currentReport()),
);
isLoading = signal(false);
isError = signal(false);
errorDetails = signal<string | null>(null);
/** Whether a background refresh of a stale calculation is currently running. */
isRefreshing = signal(false);
/** Incremented on every new calculation / criteria change to invalidate in-flight refreshes. */
private refreshToken = 0;
/** The in-flight background refresh, if any. */
private refreshTask?: Promise<void>;
reportCalculation = signal<ReportCalculation | null>(null);
localTime = computed<Date | undefined>(() => {
const endDate = this.reportCalculation()?.endDate;
if (!endDate) return undefined;
// Convert the UTC to local timezone (as the date string doesn't include timezone information (e.g. ending with "Z") we have to handle this explicitly
return moment.utc(endDate).local().toDate();
});
/** Whether the currently shown calculation is older than the staleness threshold. */
isStale = computed<boolean>(() => {
const endDate = this.reportCalculation()?.endDate;
if (!endDate) return false;
// endDate has no timezone suffix; treat it as UTC like localTime() does.
return (
moment.utc().diff(moment.utc(endDate), "seconds") >
STALE_THRESHOLD_SECONDS
);
});
data = signal<any[]>([]);
/**
* Flat rows for the tabular `object-table` renderer.
*
* The backend wraps each query's result rows in an outer array (e.g. `[[ {..}, {..} ]]`),
* so a single-query (tabular) report arrives one level too deep. Flatten that wrapping
* into a plain row list; the hierarchical renderer keeps the nested shape via `flattenData`.
*/
sqlTableRows = computed<any[]>(() => this.unwrapTabularRows(this.data()));
private unwrapTabularRows(data: any[]): any[] {
return (data ?? []).flatMap((item) =>
Array.isArray(item) ? item : [item],
);
}
exportableData = computed<any>(() => {
const data = this.data();
if (data.length === 0) return undefined;
const mode = this.mode();
const report = this.currentReport();
switch (mode) {
case "reporting":
return this.flattenReportRows(data);
case "sql":
return this.getSqlExportableData(data, report);
default:
return data;
}
});
dateRangeOptions = signal<DateRangeFilterConfigOption[]>(
this.loadDateRangeOptionsFromConfig(),
);
private loadDateRangeOptionsFromConfig(): DateRangeFilterConfigOption[] {
const reportViewConfig = this.configService.getConfig<{
config?: { filters?: DateRangeFilterConfig[] };
}>("view:report")?.config;
if (reportViewConfig?.filters?.length) {
const periodFilter = reportViewConfig.filters.find(
(f: DateRangeFilterConfig) => f.id === "reportPeriod",
);
if (periodFilter && Array.isArray(periodFilter.options)) {
return periodFilter.options;
}
}
return [];
}
async calculateResults(
selectedReport: ReportEntity,
fromDate: Date,
toDate: Date,
): Promise<void> {
const token = ++this.refreshToken;
this.isError.set(false);
this.errorDetails.set(null);
this.isLoading.set(true);
this.data.set([]);
const result = await this.getReportResults(
selectedReport,
fromDate,
toDate,
).catch((reason: ReportCalculationError | Error) => {
this.isError.set(true);
this.errorDetails.set(
(reason.message ?? reason) +
" " +
((reason as ReportCalculationError)?.reportCalculation
?.errorDetails ?? ""),
);
Logging.debug(reason.message ?? "Report Calculation Error", reason);
return { data: [] as any[], calculation: undefined };
});
this.currentReport.set(selectedReport);
this.data.set(result.data);
this.reportCalculation.set(result.calculation ?? null);
this.isLoading.set(false);
// isStale() is only true for SQL reports (only they set reportCalculation).
if (this.isStale()) {
this.refreshTask = this.backgroundRefresh(
selectedReport as SqlReport,
fromDate,
toDate,
token,
);
}
}
/**
* Re-run the SQL report with forceCalculation to replace a stale cached result.
* Runs in the background: keeps the stale data + warning visible until the fresh
* result arrives. A token guards against a slow result overwriting a report the
* user has since changed.
*/
private async backgroundRefresh(
report: SqlReport,
from: Date,
to: Date,
token: number,
) {
this.isRefreshing.set(true);
try {
const reportData = await this.sqlReportService.query(
report,
from,
to,
true,
);
const calculation = await firstValueFrom(
this.sqlReportService.fetchReportCalculation(reportData.calculation.id),
);
if (token !== this.refreshToken) return; // superseded; discard
this.data.set(reportData.data);
this.reportCalculation.set(calculation);
} catch (reason) {
Logging.warn("Background report refresh failed", reason);
// keep showing the stale data + warning
} finally {
if (token === this.refreshToken) this.isRefreshing.set(false);
}
}
private getSqlExportableData(
data: any[],
report: ReportEntity | undefined,
): any {
return isHierarchicalReport(report)
? this.sqlReportService.getCsvforV2(
this.sqlReportService.flattenData(data),
)
: this.unwrapTabularRows(data);
}
private async getReportResults(
report: ReportEntity,
from: Date,
to: Date,
): Promise<{ data: any[]; calculation?: ReportCalculation }> {
switch (report.mode) {
case "exporting":
// Add one day because to date is exclusive
const dayAfterToDate = moment(to).add(1, "day").toDate();
return {
data: await this.dataTransformationService.queryAndTransformData(
report.reportDefinition,
from,
dayAfterToDate,
),
};
case "sql":
const reportData = await this.sqlReportService.query(
report,
from,
to,
this.reportCalculation() !== null,
);
const calculation = await firstValueFrom(
this.sqlReportService.fetchReportCalculation(
reportData.calculation.id,
),
);
return { data: reportData.data, calculation };
default:
return {
data: await this.dataAggregationService.calculateReport(
report.reportDefinition,
from,
to,
),
};
}
}
private flattenReportRows(rows: any[]): { label: string; result: any }[] {
const tableRows: { label: string; result: any }[] = [];
rows.forEach((result) => {
tableRows.push(this.createExportableRow(result.header));
tableRows.push(...this.flattenReportRows(result.subRows));
});
return tableRows;
}
private createExportableRow(header: {
label: string;
groupedBy: GroupByDescription[];
result: any;
}): { label: string; result: any } {
let resultLabel = header.label;
const groupByString = getGroupingInformationString(header.groupedBy);
if (groupByString) {
resultLabel += " " + groupByString;
}
return { label: resultLabel, result: header.result };
}
onReportCriteriaChange() {
this.refreshToken++;
this.isRefreshing.set(false);
this.reportCalculation.set(null);
this.data.set([]);
}
}
<app-view-title
class="view-section"
i18n="
Reports concern a group of for example children and include data about these
children in a certain date range
"
>
Reports
</app-view-title>
<app-view-actions>
<div class="flex-row gap-regular">
<button mat-icon-button color="primary" [matMenuTriggerFor]="contextMenu">
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
</div>
<mat-menu #contextMenu>
<button
mat-menu-item
[routerLink]="[reportAdminLink]"
*appDisabledEntityOperation="{
entity: reportEntity,
operation: 'create',
}"
angulartics2On="click"
angularticsCategory="Reporting"
angularticsAction="manageReports"
>
<fa-icon
class="color-accent standard-icon-with-text"
icon="gear"
></fa-icon>
<span i18n>Manage Reports</span>
</button>
</mat-menu>
</app-view-actions>
<app-select-report
[reports]="reports()"
[loading]="isLoading()"
[exportableData]="exportableData()"
[dateRangeOptions]="dateRangeOptions()"
(calculateClick)="calculateResults($event.report, $event.from, $event.to)"
(reportFiltersChange)="onReportCriteriaChange()"
(selectedReportChange)="currentReport.set($event); onReportCriteriaChange()"
class="view-section"
></app-select-report>
@if (currentReport()?.mode === "sql") {
<app-feature-disabled-info
class="view-section"
featureName="SQL reports"
i18n-featureName="ReportConfig SQL feature name"
[featureEnabled]="reportingBackendEnabled.value()"
></app-feature-disabled-info>
}
@if (reportCalculation()) {
@if (isStale()) {
<div
class="flex-row align-center gap-small color-error padding-top-small padding-left-small"
>
<fa-icon icon="triangle-exclamation"></fa-icon>
<span i18n
>You are seeing a previous calculation. Data may be outdated.</span
>
@if (isRefreshing()) {
<span class="text-secondary" i18n>(updating…)</span>
}
</div>
}
<div style="color: gray; padding: 10px">
<span i18n>This report was calculated at:</span>
{{ localTime() | customDate: "short" }} <br />
<span i18n
>Click again on "Calculate" to re-calculate the report including any
changes of records since then.</span
>
</div>
}
@if (isError()) {
<div class="error-message">
<h3 class="header" i18n>Something went wrong calculating the report</h3>
<div class="content">
<p i18n>
Please try again. If you continue to see this error, contact the
technical support team.
</p>
<p i18n>Error details:</p>
<code>
{{ errorDetails() | json }}
</code>
</div>
</div>
}
@if (data()?.length > 0 && mode() === "reporting") {
<app-report-row [rows]="data()"></app-report-row>
}
@if (data()?.length > 0 && mode() === "exporting") {
<app-object-table [objects]="data()"></app-object-table>
}
@if (data()?.length > 0 && mode() === "sql" && !isHierarchicalReport()) {
<app-object-table [objects]="sqlTableRows()"></app-object-table>
}
@if (data()?.length > 0 && mode() === "sql" && isHierarchicalReport()) {
<app-sql-v2-table [reportData]="data()"></app-sql-v2-table>
}
./reporting.component.scss
@use "variables/colors";
.work-panel {
padding-top: 20px;
padding-bottom: 20px;
border-radius: 0;
}
.primary-button {
background-color: white !important;
}
.view-section {
margin-bottom: 1em;
}
.report-description {
white-space: pre-wrap;
margin: 0;
}
.error-message {
margin-top: 12px;
border-radius: 12px;
border: 1px solid colors.$grey-darker;
.header {
border-top-left-radius: 12px;
border-top-right-radius: 12px;
background: colors.$error;
padding: 12px;
color: colors.$grey-light;
}
.content {
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
padding-left: 12px;
padding-right: 12px;
padding-bottom: 12px;
background: white;
font-size: small;
color: colors.$text-secondary;
}
}