blob: 4dc5de05a8c2975c07b274ac3a3fb3993da88324 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { CdkTableModule } from '@angular/cdk/table';
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
IonButton,
IonCard,
IonCardContent,
IonCardHeader,
IonCardTitle,
IonDatetime,
IonDatetimeButton,
IonIcon,
IonInput,
IonItem,
IonLabel,
IonModal,
IonNote,
IonSelect,
IonSelectOption,
IonSpinner,
} from '@ionic/angular/standalone';
import { DOWNLOAD } from '../../core/adapters';
import { NotificationService } from '../../core/services/notification.service';
import { toIsoDate } from '../../core/utils/date-formatter';
import { HelpIconComponent } from '../../shared';
import { BarChartComponent } from '../../shared/components/charts/bar-chart.component';
import {
ChartData,
DonutChartComponent,
} from '../../shared/components/charts/donut-chart.component';
import { PaginatorComponent } from '../../shared/components/paginator/paginator.component';
import { PageEvent } from '../../shared/models/table.model';
import {
ALL_OPTION,
ReportExecutionService,
ReportParameter,
ReportParameterValues,
ReportSelectOption,
offersAllOption,
} from './report-execution.service';
const SUPPORTED_DISPLAY_TYPES = new Set(['select', 'date', 'text', 'none']);
/**
* Report types are declared by the report definition and, in this platform, are exactly
* `Table`, `Chart` and `SMS` — posting anything else answers
* `validation.msg.report.reportType.is.not.one.of.expected.enumerations`. Everything that is not
* a chart renders as a table, which is what `SMS` and `Email` definitions want anyway.
*/
const CHART_REPORT_TYPE = 'chart';
/** The platform's two chart sub-types are `Bar` and `Pie`; `Bar` is the default. */
const PIE_SUB_TYPE = 'pie';
/** `columnDisplayType` values that can be plotted. Anything else can only be a category. */
const NUMERIC_DISPLAY_TYPES = new Set(['INTEGER', 'DECIMAL', 'REAL', 'DOUBLE', 'FLOAT', 'NUMBER']);
/** Distinct hues for a report grouped by branch or product; beyond these they repeat. */
const CHART_PALETTE = [
'#3880ff',
'#2dd36f',
'#ffc409',
'#eb445a',
'#6030ff',
'#0cd1e8',
'#f4a261',
'#8e44ad',
];
type OptionStatus = 'idle' | 'loading' | 'loaded' | 'failed';
/** What a report parameter can hold: the platform sends every value back as a query string. */
type ParameterValue = string | number | undefined;
interface DependentOptionState {
readonly status: OptionStatus;
readonly options: readonly ReportSelectOption[];
}
const IDLE_STATE: DependentOptionState = { status: 'idle', options: [] };
/**
* What one parameter control needs to render.
*
* Built as a computed rather than by template methods: a method returning a fresh array or object
* on every change-detection pass gives Angular a new identity each time, which is an NG0100 under
* the exhaustive check-no-changes configured for dev builds.
*/
interface ReportParameterView {
readonly parameter: ReportParameter;
readonly options: readonly ReportSelectOption[];
readonly value: ParameterValue;
readonly stringValue: string | undefined;
readonly disabled: boolean;
readonly waitingForParent: boolean;
readonly loading: boolean;
readonly failed: boolean;
readonly parentLabel: string;
readonly controlId: string;
readonly datePickerId: string;
readonly testId: string;
}
@Component({
selector: 'app-run-report',
standalone: true,
imports: [
FormsModule,
TranslateModule,
CdkTableModule,
PaginatorComponent,
HelpIconComponent,
BarChartComponent,
DonutChartComponent,
IonIcon,
IonButton,
IonItem,
IonLabel,
IonCardContent,
IonCardHeader,
IonCardTitle,
IonCard,
IonSelectOption,
IonSelect,
IonDatetime,
IonDatetimeButton,
IonInput,
IonModal,
IonNote,
IonSpinner,
],
template: `
<div class="form-container">
<ion-card>
<ion-card-header>
<ion-card-title>
{{ 'REPORTS.RUN_TITLE' | translate }}: {{ reportName() }}
<app-help-icon [helpTextKey]="'HELP.REPORTS_DESC'"></app-help-icon>
</ion-card-title>
</ion-card-header>
<ion-card-content>
@if (isParametersLoading()) {
<div class="parameter-status" data-testid="report-parameters-loading">
<ion-spinner name="crescent"></ion-spinner>
<span>{{ 'REPORTS.PARAMETERS_LOADING' | translate }}</span>
</div>
} @else if (parameterLoadFailed()) {
<ion-note color="danger" data-testid="report-parameters-error">
{{ 'REPORTS.PARAMETERS_LOAD_FAILED' | translate }}
</ion-note>
} @else if (unsupportedParameters().length > 0) {
<ion-note color="danger" data-testid="report-parameters-unsupported">
{{ 'REPORTS.PARAMETERS_UNSUPPORTED' | translate }}:
{{ unsupportedParameterNames() }}
</ion-note>
}
<div class="report-parameters form-grid">
@for (view of parameterViews(); track view.parameter.queryParameter) {
@switch (view.parameter.displayType) {
@case ('select') {
<div class="parameter-field">
<ion-item fill="outline" [attr.data-testid]="view.testId" [id]="view.controlId">
<ion-label position="stacked">{{ view.parameter.label }}</ion-label>
<ion-select
[id]="view.controlId + '-select'"
[attr.data-testid]="view.testId + '-select'"
[attr.aria-label]="view.parameter.label"
interface="popover"
[disabled]="view.disabled"
[value]="view.value"
(ionChange)="onParameterChange(view.parameter, $event)"
>
@for (option of view.options; track option.id) {
<ion-select-option [value]="option.id">
{{ option.isAll ? ('COMMON.ALL' | translate) : option.name }}
</ion-select-option>
}
</ion-select>
</ion-item>
@if (view.waitingForParent) {
<ion-note class="field-note" [attr.data-testid]="view.testId + '-waiting'">
{{
'REPORTS.PARAMETER_SELECT_PARENT_FIRST'
| translate: { parameter: view.parentLabel }
}}
</ion-note>
} @else if (view.loading) {
<ion-note class="field-note" [attr.data-testid]="view.testId + '-loading'">
{{ 'REPORTS.PARAMETER_OPTIONS_LOADING' | translate }}
</ion-note>
} @else if (view.failed) {
<ion-note
class="field-note"
color="danger"
[attr.data-testid]="view.testId + '-error'"
>
{{ 'REPORTS.PARAMETER_OPTIONS_FAILED' | translate }}
</ion-note>
}
</div>
}
@case ('date') {
<div class="parameter-field">
<ion-item fill="outline" [attr.data-testid]="view.testId" [id]="view.controlId">
<ion-label position="stacked">{{ view.parameter.label }}</ion-label>
<ion-datetime-button
[id]="view.controlId + '-button'"
[attr.data-testid]="view.testId + '-button'"
[datetime]="view.datePickerId"
></ion-datetime-button>
<ion-modal [keepContentsMounted]="true">
<ng-template>
<ion-datetime
presentation="date"
[id]="view.datePickerId"
[attr.data-testid]="view.datePickerId"
[value]="view.stringValue"
(ionChange)="onParameterChange(view.parameter, $event)"
></ion-datetime>
</ng-template>
</ion-modal>
</ion-item>
</div>
}
@case ('text') {
<div class="parameter-field">
<ion-item fill="outline" [attr.data-testid]="view.testId" [id]="view.controlId">
<ion-label position="stacked">{{ view.parameter.label }}</ion-label>
<ion-input
[id]="view.controlId + '-input'"
[attr.data-testid]="view.testId + '-input'"
type="text"
[attr.aria-label]="view.parameter.label"
[value]="view.stringValue"
(ionInput)="onParameterChange(view.parameter, $event)"
></ion-input>
</ion-item>
</div>
}
}
}
</div>
@if (parametersLoaded() && !parameterLoadFailed() && !canRun()) {
<ion-note data-testid="report-parameters-incomplete">
{{ 'REPORTS.PARAMETERS_INCOMPLETE' | translate }}
</ion-note>
}
<div class="form-actions">
<ion-button
id="cancel-report"
data-testid="cancel-report"
fill="clear"
(click)="onCancel()"
>{{ 'COMMON.CANCEL' | translate }}</ion-button
>
<ion-button
id="download-report-csv"
data-testid="download-report-csv"
color="secondary"
(click)="onDownloadCSV()"
[disabled]="!canRun() || isLoading()"
>
<ion-icon name="download-outline"></ion-icon>
{{ 'REPORTS.DOWNLOAD_CSV' | translate }}
</ion-button>
<ion-button
id="run-report"
data-testid="run-report"
color="primary"
(click)="onRun()"
[disabled]="!canRun() || isLoading()"
>
{{ isLoading() ? ('COMMON.LOADING' | translate) : ('REPORTS.RUN' | translate) }}
</ion-button>
</div>
@if (reportData()) {
<div class="report-results mt-4">
<hr class="divider" />
<div class="results-header">
<h3 class="mt-2">{{ 'REPORTS.RESULTS' | translate }}</h3>
<ion-button
id="download-report-results-csv"
data-testid="download-report-results-csv"
color="primary"
(click)="downloadCSV()"
>
<ion-icon name="download-outline"></ion-icon>
{{ 'REPORTS.DOWNLOAD_RESULTS_CSV' | translate }}
</ion-button>
</div>
@if (chartUnavailable()) {
<ion-note color="warning" data-testid="report-chart-unavailable">
{{ 'REPORTS.CHART_UNAVAILABLE' | translate }}
</ion-note>
}
@if (showsChart()) {
<div class="chart-wrapper" data-testid="report-chart">
@if (isPieChart()) {
<app-donut-chart
data-testid="report-chart-pie"
[data]="chartSeries()"
></app-donut-chart>
} @else {
<app-bar-chart
data-testid="report-chart-bar"
[data]="chartSeries()"
></app-bar-chart>
}
</div>
} @else {
<div class="table-container" data-testid="report-table">
<table cdk-table [dataSource]="pagedRows()">
@for (col of displayedColumns(); track col; let i = $index) {
<ng-container [cdkColumnDef]="col">
<th cdk-header-cell *cdkHeaderCellDef>{{ col }}</th>
<td cdk-cell *cdkCellDef="let row">{{ getReportCellValue(row, i) }}</td>
</ng-container>
}
<tr cdk-header-row *cdkHeaderRowDef="displayedColumns()"></tr>
<tr cdk-row *cdkRowDef="let row; columns: displayedColumns()"></tr>
</table>
<app-paginator
[length]="dataRows().length"
[pageSize]="pageSize()"
[pageIndex]="pageIndex()"
[pageSizeOptions]="[10, 20, 50, 100]"
(page)="onPage($event)"
></app-paginator>
</div>
}
</div>
}
</ion-card-content>
</ion-card>
</div>
`,
styles: [
`
.form-container {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.parameter-status {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.parameter-field {
display: flex;
flex-direction: column;
}
.field-note {
margin: 4px 0 0;
font-size: 12px;
}
.chart-wrapper {
display: flex;
justify-content: center;
margin-top: 16px;
}
ion-note {
display: block;
margin: 8px 0 16px;
}
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
margin-bottom: 8px;
}
.table-container {
overflow-x: auto;
margin-top: 16px;
}
.mt-4 {
margin-top: 2rem;
}
.mt-2 {
margin-top: 1rem;
}
@media (max-width: 900px) {
.form-grid {
grid-template-columns: 1fr;
}
}
`,
],
})
export class RunReportComponent implements OnInit {
private readonly reportExecution = inject(ReportExecutionService);
private readonly download = inject(DOWNLOAD);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly notifications = inject(NotificationService);
private readonly translate = inject(TranslateService);
readonly reportName = signal('');
/** Declared by the report definition and carried on the query string by the list screen. */
readonly reportType = signal('');
readonly reportSubType = signal('');
readonly isLoading = signal(false);
readonly isParametersLoading = signal(false);
readonly parametersLoaded = signal(false);
readonly parameterLoadFailed = signal(false);
readonly parameters = signal<ReportParameter[]>([]);
readonly parameterValues = signal<Record<string, string | number>>({});
/** Options for parameters whose lookup is scoped by a parent, keyed by parameter name. */
readonly dependentOptions = signal<Record<string, DependentOptionState>>({});
readonly unsupportedParameters = computed(() =>
this.parameters().filter((parameter) => !SUPPORTED_DISPLAY_TYPES.has(parameter.displayType)),
);
readonly unsupportedParameterNames = computed(() =>
this.unsupportedParameters()
.map((parameter) => `${parameter.label} (${parameter.displayType})`)
.join(', '),
);
readonly canRun = computed(
() =>
this.parametersLoaded() &&
!this.parameterLoadFailed() &&
this.unsupportedParameters().length === 0 &&
this.parameters().every((parameter) =>
this.hasValue(this.parameterValues()[parameter.queryParameter]),
),
);
/**
* The parameter controls, resolved against the current values and dependent lookups.
*
* A dependent parameter is disabled until its parent has a value: rendering it as a flat list of
* every loan officer in the institution both is unusable across a branch network and lets an
* officer be chosen who does not work in the selected office, which returns an empty report with
* nothing to explain it.
*/
readonly parameterViews = computed<ReportParameterView[]>(() => {
const parameters = this.parameters();
const values = this.parameterValues();
const dependents = this.dependentOptions();
const byName = new Map(parameters.map((parameter) => [parameter.name, parameter]));
return parameters.map((parameter, index) => {
const parent = parameter.parentParameterName
? byName.get(parameter.parentParameterName)
: undefined;
const state = dependents[parameter.name] ?? IDLE_STATE;
const waitingForParent =
parent !== undefined && !this.hasValue(values[parent.queryParameter]);
const value = values[parameter.queryParameter];
const controlId = `report-parameter-${this.safeIdentifier(parameter.variable)}-${index}`;
return {
parameter,
options: parent ? state.options : parameter.options,
value,
stringValue: value === undefined ? undefined : String(value),
disabled: waitingForParent || state.status === 'loading',
waitingForParent,
loading: state.status === 'loading',
failed: parent ? state.status === 'failed' : parameter.optionsFailed,
parentLabel: parent?.label ?? '',
controlId,
datePickerId: `${controlId}-picker`,
testId: `report-parameter-${this.safeIdentifier(parameter.variable)}`,
};
});
});
readonly reportData = signal<Record<string, unknown> | null>(null);
readonly displayedColumns = signal<string[]>([]);
readonly columnHeaders = signal<Record<string, unknown>[]>([]);
readonly dataRows = signal<Record<string, unknown>[]>([]);
/** Report results are fetched whole, so paging happens client-side. */
readonly pageIndex = signal(0);
readonly pageSize = signal(10);
readonly rows = signal<Record<string, unknown>[]>([]);
readonly pagedRows = computed(() => {
const start = this.pageIndex() * this.pageSize();
return this.rows().slice(start, start + this.pageSize());
});
readonly isChartReport = computed(() => this.reportType().toLowerCase() === CHART_REPORT_TYPE);
readonly isPieChart = computed(() => this.reportSubType().toLowerCase() === PIE_SUB_TYPE);
/**
* The plottable series behind a chart report.
*
* A chart report returns the *same* generic resultset as a table report — the platform does no
* chart-specific work at all, so the shape has to be inferred here: the first numeric column is
* the value and the first column that is not it is the category.
*/
readonly chartSeries = computed<ChartData[]>(() => {
if (!this.isChartReport()) return [];
const headers = this.columnHeaders();
const rows = this.dataRows();
if (headers.length === 0 || rows.length === 0) return [];
const valueIndex = headers.findIndex((header) =>
NUMERIC_DISPLAY_TYPES.has(String(header['columnDisplayType']).toUpperCase()),
);
if (valueIndex === -1) return [];
const labelIndex = headers.findIndex((_, index) => index !== valueIndex);
return rows
.map((row, index) => ({
label: labelIndex === -1 ? String(index + 1) : this.getReportCellValue(row, labelIndex),
value: Number(this.getReportCellValue(row, valueIndex)),
color: CHART_PALETTE[index % CHART_PALETTE.length],
}))
.filter((point) => Number.isFinite(point.value));
});
readonly showsChart = computed(() => this.isChartReport() && this.chartSeries().length > 0);
/**
* A report can be *declared* a chart while returning nothing plottable — a definition whose SQL
* selects only text columns, say. Falling back to the table is better than an empty panel, but
* it has to say why it did.
*/
readonly chartUnavailable = computed(
() => this.isChartReport() && this.reportData() !== null && this.chartSeries().length === 0,
);
ngOnInit(): void {
this.route.paramMap.subscribe((params) => {
const reportName = params.get('reportName') || '';
this.reportName.set(reportName);
this.loadParameters(reportName);
});
this.route.queryParamMap.subscribe((params) => {
this.reportType.set(params.get('type') || '');
this.reportSubType.set(params.get('subType') || '');
});
}
onPage(event: PageEvent): void {
this.pageIndex.set(event.pageIndex);
this.pageSize.set(event.pageSize);
}
onParameterChange(parameter: ReportParameter, event: Event): void {
const detailValue = (event as CustomEvent<{ value?: string | number | null }>).detail?.value;
const targetValue = (event.target as HTMLInputElement | null)?.value;
const value = detailValue ?? targetValue;
if (value !== undefined && value !== null) {
this.setParameterValue(parameter, value);
}
}
setParameterValue(parameter: ReportParameter, value: string | number): void {
this.parameterValues.update((current) => ({
...current,
[parameter.queryParameter]: value,
}));
this.refreshDependents(parameter, value);
}
parameterValue(parameter: ReportParameter): string | number | undefined {
return this.parameterValues()[parameter.queryParameter];
}
/**
* Reloads everything downstream of a parameter that has just changed.
*
* The child is cleared before it is refetched, not merely refetched: leaving the previously
* chosen loan officer selected after the office changes is how a report ends up filtered to an
* officer who does not work there, which returns no rows and says nothing about why.
*/
private refreshDependents(parameter: ReportParameter, parentValue: string | number): void {
for (const child of this.childrenOf(parameter.name)) {
this.clearParameter(child, new Set([parameter.name]));
this.loadDependentOptions(child, parameter.queryParameter, parentValue);
}
}
private childrenOf(name: string): ReportParameter[] {
return this.parameters().filter((parameter) => parameter.parentParameterName === name);
}
/**
* Clears a parameter and everything downstream of it.
*
* `seen` guards against a report definition that names a parent cycle: the parameter list is
* tenant data, and a cycle in it would otherwise hang the browser rather than break the report.
*/
private clearParameter(parameter: ReportParameter, seen: Set<string>): void {
if (seen.has(parameter.name)) return;
seen.add(parameter.name);
this.parameterValues.update((current) => {
const next = { ...current };
delete next[parameter.queryParameter];
return next;
});
this.dependentOptions.update((current) => ({ ...current, [parameter.name]: IDLE_STATE }));
for (const child of this.childrenOf(parameter.name)) {
this.clearParameter(child, seen);
}
}
private loadDependentOptions(
parameter: ReportParameter,
parentQueryParameter: string,
parentValue: string | number,
): void {
this.setDependentState(parameter, 'loading', []);
this.reportExecution
.getDependentOptions(parameter, parentQueryParameter, parentValue)
.subscribe({
next: (options) => this.setDependentState(parameter, 'loaded', options),
// A lookup can fail for reasons the user cannot act on — the stock loan-officer lookup
// compares a bigint column against a bound string and fails outright on PostgreSQL. "All"
// is declared by the parameter rather than discovered by the lookup, so offering it keeps
// the report runnable unfiltered instead of blocking it entirely.
error: () =>
this.setDependentState(
parameter,
'failed',
offersAllOption(parameter) ? [ALL_OPTION] : [],
),
});
}
private setDependentState(
parameter: ReportParameter,
status: OptionStatus,
options: readonly ReportSelectOption[],
): void {
this.dependentOptions.update((current) => ({
...current,
[parameter.name]: { status, options },
}));
}
onDownloadCSV(): void {
if (!this.canRun()) return;
this.isLoading.set(true);
this.reportExecution.downloadCsv(this.reportName(), this.collectedValues()).subscribe({
next: (data) => {
this.download.saveText(data, this.csvFilename(), 'text/csv;charset=utf-8;');
this.isLoading.set(false);
},
error: () => this.handleRunError(),
});
}
onRun(): void {
if (!this.canRun()) return;
this.isLoading.set(true);
this.reportExecution.runReport(this.reportName(), this.collectedValues()).subscribe({
next: (data) => {
const result = data as Record<string, unknown>;
this.reportData.set(result);
const columnHeaders = (result['columnHeaders'] as Record<string, unknown>[]) || [];
this.columnHeaders.set(columnHeaders);
this.displayedColumns.set(columnHeaders.map((header) => header['columnName'] as string));
const dataRows = (result['data'] as Record<string, unknown>[]) || [];
this.dataRows.set(dataRows);
this.rows.set(dataRows);
this.pageIndex.set(0);
this.isLoading.set(false);
},
error: () => this.handleRunError(),
});
}
downloadCSV(): void {
const displayedColumns = this.displayedColumns();
const dataRows = this.dataRows();
if (!displayedColumns.length || !dataRows.length) {
return;
}
const csvRows: string[] = [];
csvRows.push(displayedColumns.map((col) => `"${col.replaceAll('"', '""')}"`).join(','));
for (const row of dataRows) {
const values = displayedColumns.map((_, i) => {
const val = this.getReportCellValue(row, i);
return `"${val.replaceAll('"', '""')}"`;
});
csvRows.push(values.join(','));
}
// The BOM makes Excel read the file as UTF-8; without it, accented client names in a
// report downloaded on a Windows machine open as mojibake.
this.download.saveText(
'\uFEFF' + csvRows.join('\n'),
this.csvFilename(),
'text/csv;charset=utf-8;',
);
}
onCancel(): void {
this.router.navigate(['/reporting']);
}
getReportCellValue(row: Record<string, unknown>, index: number): string {
const rowData = row['row'];
if (rowData && Array.isArray(rowData)) {
const val = rowData[index];
if (val === null || val === undefined) {
return '';
}
if (Array.isArray(val) && val.length >= 3) {
return new Date(val[0], val[1] - 1, val[2]).toLocaleDateString();
}
return String(val);
}
return '';
}
private loadParameters(reportName: string): void {
this.isParametersLoading.set(true);
this.parametersLoaded.set(false);
this.parameterLoadFailed.set(false);
this.parameters.set([]);
this.parameterValues.set({});
this.dependentOptions.set({});
this.reportExecution.getReportParameters(reportName).subscribe({
next: (parameters) => {
this.parameters.set(parameters);
this.parameterValues.set(
Object.fromEntries(
parameters
.filter((parameter) => parameter.displayType === 'none')
.map((parameter) => [parameter.queryParameter, String(parameter.defaultValue ?? '')]),
),
);
this.parametersLoaded.set(true);
this.isParametersLoading.set(false);
},
error: () => {
this.parameterLoadFailed.set(true);
this.isParametersLoading.set(false);
this.notifications.error(this.translate.instant('REPORTS.PARAMETERS_LOAD_FAILED'));
},
});
}
private collectedValues(): ReportParameterValues {
const current = this.parameterValues();
return Object.fromEntries(
this.parameters().map((parameter) => {
const value = current[parameter.queryParameter];
return [
parameter.queryParameter,
parameter.displayType === 'date' ? toIsoDate(String(value)) : value,
];
}),
);
}
private hasValue(value: string | number | undefined): boolean {
return value !== undefined && String(value).trim().length > 0;
}
private handleRunError(): void {
this.notifications.error(this.translate.instant('COMMON.ERROR'));
this.isLoading.set(false);
}
private safeIdentifier(value: string): string {
return value.replaceAll(/[^a-zA-Z0-9_-]/g, '-');
}
/** Filename shared by both CSV paths, so they cannot drift apart. */
private csvFilename(): string {
return `${this.reportName().replaceAll(/\s+/g, '_')}_Report.csv`;
}
}