src/app/features/reporting/reporting/reporting.component.ts

Metadata

Relationships

Index

Properties
Methods

Methods

Async calculateResults
calculateResults(selectedReport: ReportEntity, fromDate: Date, toDate: Date)
Parameters :
Name Type Optional
selectedReport ReportEntity No
fromDate Date No
toDate Date No
Returns : Promise<void>
onReportCriteriaChange
onReportCriteriaChange()
Returns : void

Properties

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)
exportableData
Type : unknown
Default value : 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; } })
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.

isStale
Type : unknown
Default value : 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 ); })

Whether the currently shown calculation is older than the staleness threshold.

localTime
Type : unknown
Default value : 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(); })
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

Readonly reportingBackendEnabled
Type : unknown
Default value : resource({ loader: () => this.sqlReportService.isReportingBackendEnabled(), })

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.

reports
Type : unknown
Default value : this.reportsResource.value
sqlTableRows
Type : unknown
Default value : computed<any[]>(() => this.unwrapTabularRows(this.data()))

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.

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;
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""