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 { 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 { HttpErrorResponse } from "@angular/common/http";
import { SqlV2TableComponent } from "./sql-v2-table/sql-v2-table.component";
import { ConfigService } from "app/core/config/config.service";
import { resolveLocaleText } from "app/core/language/active-locale";
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,
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.toSorted((a, b) =>
resolveLocaleText(a.title)?.localeCompare(
resolveLocaleText(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 | HttpErrorResponse | Error) => {
this.isError.set(true);
this.errorDetails.set(this.getErrorDetails(reason));
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,
);
}
}
/**
* The details of a failed calculation for the user. For a failed request, this is the message
* the backend sends (e.g. naming the invalid entry of a report definition), if any,
* rather than the generic "Http failure response for …".
*/
private getErrorDetails(
reason: ReportCalculationError | HttpErrorResponse | Error,
): string {
const backendMessage: string | undefined =
reason instanceof HttpErrorResponse
? reason.error?.errorMessage
: undefined;
const calculationDetails =
reason instanceof ReportCalculationError
? reason.reportCalculation?.errorDetails
: undefined;
return [
backendMessage ?? reason.message ?? String(reason),
calculationDetails,
]
.filter((part) => !!part)
.join(" ");
}
/**
* 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) {
if (reason instanceof ReportCalculationError) {
// the calculation itself failed (e.g. a problem in the report definition),
// which the user is shown when they calculate the report explicitly
Logging.debug("Background report refresh failed", reason);
} else {
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([]);
}
}