ngx-t-reports
Version:
Angular module for creating dynamic reports and dashboards. Supports various data sources, custom templates, and real-time updates.
984 lines (973 loc) • 298 kB
JavaScript
import * as i0 from '@angular/core';
import { EventEmitter, Output, Input, Component, Pipe, ChangeDetectionStrategy, Injectable, inject, ViewChild, InjectionToken, Optional, Inject } from '@angular/core';
import * as i1 from 'ngx-echarts';
import { NgxEchartsModule, provideEchartsCore } from 'ngx-echarts';
import * as echarts from 'echarts/core';
import { TitleComponent, TooltipComponent, LegendComponent, GridSimpleComponent, DataZoomComponent, GridComponent, AriaComponent, AxisPointerComponent, BrushComponent, CalendarComponent, DatasetComponent, GraphicComponent, MarkAreaComponent, MarkLineComponent, MarkPointComponent, ParallelComponent, PolarComponent, RadarComponent, SingleAxisComponent, TimelineComponent, ToolboxComponent, TransformComponent, VisualMapComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import * as i2 from '@angular/common';
import { CommonModule, NgStyle } from '@angular/common';
import { BarChart, BoxplotChart, CandlestickChart, CustomChart, EffectScatterChart, FunnelChart, GaugeChart, GraphChart, HeatmapChart, LineChart, LinesChart, MapChart, ParallelChart, PictorialBarChart, PieChart, RadarChart, SankeyChart, ScatterChart, SunburstChart, ThemeRiverChart, TreeChart, TreemapChart } from 'echarts/charts';
import * as i2$1 from '@angular/material/progress-spinner';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import * as i3 from '@angular/cdk/drag-drop';
import { moveItemInArray, DragDropModule, CdkDropList, CdkDrag } from '@angular/cdk/drag-drop';
import * as i6 from '@angular/material/button';
import { MatButtonModule } from '@angular/material/button';
import * as i1$1 from '@angular/material/card';
import { MatCardModule } from '@angular/material/card';
import * as i6$1 from '@angular/material/icon';
import { MatIconModule } from '@angular/material/icon';
import * as i4 from '@angular/material/menu';
import { MatMenuModule } from '@angular/material/menu';
import * as i5 from '@angular/material/tooltip';
import { MatTooltipModule } from '@angular/material/tooltip';
import * as i8 from '@angular/material/toolbar';
import { MatToolbarModule, MatToolbar } from '@angular/material/toolbar';
import * as i4$1 from '@angular/material/chips';
import { MatChipsModule } from '@angular/material/chips';
import * as i10 from '@angular/material/table';
import { MatTableModule } from '@angular/material/table';
import { ComponentStore } from '@ngrx/component-store';
import { combineLatest, map, shareReplay, distinctUntilChanged, switchMap, take, tap, BehaviorSubject, Observable, Subscription, catchError, EMPTY, withLatestFrom, filter, of, forkJoin } from 'rxjs';
import ExcelJS from 'exceljs';
import * as i1$2 from '@angular/material/dialog';
import { ElementEditorTypes, SpecialElementKeys, DataSources, InputTypes, InputDataTypes, ElementTypes, MinInputTypes } from 'ngx-t-forms-types';
import { format } from 'date-fns';
import * as i4$2 from '@angular/forms';
import { FormGroup, FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import * as i3$1 from '@angular/material/datepicker';
import { MatDatepickerModule } from '@angular/material/datepicker';
import * as i4$3 from '@angular/material/divider';
import { MatDividerModule } from '@angular/material/divider';
import * as i1$3 from '@angular/material/form-field';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MAT_DATE_LOCALE, DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
import * as i5$1 from '@angular/material/expansion';
import { MatExpansionModule } from '@angular/material/expansion';
import { assignDeepPropertyToObject, TDynamicDataEditComponent, NGX_T_FORMS_CONFIG_TOKEN, getSectionElements } from 'ngx-t-forms';
import { MomentDateAdapter } from '@angular/material-moment-adapter';
import moment from 'moment-timezone';
import moment$1 from 'moment';
/**
* Compares previous and current objects, returning only properties that changed.
* - Properties in prev but not in current: set to undefined
* - Properties in current but not in prev: included with current value
* - Properties in both with different values: included with current value
*
* @example
* // prev = {a: 8, b: 0}, current = {b: 5}
* // result = {b: 5, a: undefined}
*/
function getChangedProperties(prevPassed, currentPassed) {
// Handle edge cases
// remove undefined in prev
const prev = prevPassed ? Object.keys(prevPassed).reduce((acc, key) => {
if (prevPassed[key] !== undefined) {
acc[key] = prevPassed[key];
}
return acc;
}, {}) : undefined;
// remove undefined in current
const current = currentPassed ? Object.keys(currentPassed).reduce((acc, key) => {
if (currentPassed[key] !== undefined) {
acc[key] = currentPassed[key];
}
return acc;
}, {}) : undefined;
// if both are undefined return empty object
if (!prev && !current)
return {};
if (!prev)
return { ...current };
if (!current) {
return Object.keys(prev).reduce((acc, key) => {
acc[key] = undefined;
return acc;
}, {});
}
const changedProperties = {};
// Check properties from prev
for (const key in prev) {
if (!(key in current)) {
// Property removed in current
changedProperties[key] = undefined;
}
else if (!_isEqual(prev[key], current[key])) {
// Property value changed
changedProperties[key] = current[key];
}
}
// Check for new properties in current
for (const key in current) {
if (!(key in prev)) {
// New property added
changedProperties[key] = current[key];
}
}
return changedProperties;
}
function _isEqual(prev, next) {
if (prev === next)
return true;
const prevType = typeof prev;
const nextType = typeof next;
if (prevType !== nextType)
return false;
if (prevType === 'number') {
return (prev === next) || (Number.isNaN(prev) && Number.isNaN(next));
}
if (prevType === 'string' || prevType === 'boolean') {
return prev === next;
}
if (Array.isArray(prev) && Array.isArray(next)) {
if (prev.length !== next.length)
return false;
for (let i = 0; i < prev.length; i++) {
if (!_isEqual(prev[i], next[i]))
return false;
}
return true;
}
if (prevType === 'object' && prev !== null && next !== null) {
const prevConstructor = prev.constructor;
const nextConstructor = next.constructor;
if (prevConstructor !== nextConstructor)
return false;
if (prevConstructor === Date) {
return prev.getTime() === next.getTime();
}
if (prevConstructor === RegExp) {
return prev.source === next.source && prev.flags === next.flags;
}
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(next);
if (prevKeys.length !== nextKeys.length)
return false;
for (const key of prevKeys) {
if (!Object.prototype.hasOwnProperty.call(next, key))
return false;
if (!_isEqual(prev[key], next[key]))
return false;
}
return true;
}
return prev === null && next === null;
}
echarts.use([
BarChart, BoxplotChart, CandlestickChart, CustomChart, EffectScatterChart,
TitleComponent, TooltipComponent,
LegendComponent,
GridSimpleComponent,
DataZoomComponent,
FunnelChart, GaugeChart, GraphChart, HeatmapChart, LineChart, LinesChart,
MapChart, ParallelChart, PictorialBarChart, PieChart, RadarChart, SankeyChart,
ScatterChart, SunburstChart, ThemeRiverChart, TreeChart, TreemapChart,
GridComponent, CanvasRenderer,
AriaComponent,
AxisPointerComponent,
BrushComponent,
CalendarComponent,
DatasetComponent,
GraphicComponent,
MarkAreaComponent,
MarkLineComponent,
MarkPointComponent,
ParallelComponent,
PolarComponent,
RadarComponent,
SingleAxisComponent,
TimelineComponent,
ToolboxComponent,
TransformComponent,
VisualMapComponent,
]);
/** A chart shorter than this cannot fit a legend plus a readable plot area. */
const DEFAULT_CHART_HEIGHT = 280;
class EChartsComponent {
constructor() {
this.chartClick = new EventEmitter();
}
get options() {
return this._options;
}
/**
* Held by reference: `options` is a signal input on the ngx-echarts directive,
* so an equal-but-new object would still force a full chart re-render.
*/
set options(value) {
if (_isEqual(this._options, value))
return;
this._options = value;
}
get resolvedHeight() {
if (this.height === undefined || this.height === null || this.height === '') {
return `${DEFAULT_CHART_HEIGHT}px`;
}
return typeof this.height === 'number' || /^\d+(\.\d+)?$/.test(String(this.height))
? `${this.height}px`
: String(this.height);
}
onChartClick(event) {
this.chartClick.emit(event);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: EChartsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: EChartsComponent, isStandalone: true, selector: "app-e-charts", inputs: { loading: "loading", options: "options", height: "height" }, outputs: { chartClick: "chartClick" }, providers: [
provideEchartsCore({ echarts }),
], ngImport: i0, template: "@if (options) {\n<div echarts class=\"chart\" [style.height]=\"resolvedHeight\" [options]=\"options\" [autoResize]=\"true\" [loading]=\"loading\"\n (chartClick)=\"onChartClick($event)\"></div>\n}\n", styles: [":host{display:block;width:100%;min-width:0}.chart{width:100%;min-width:0}\n"], dependencies: [{ kind: "ngmodule", type: NgxEchartsModule }, { kind: "directive", type: i1.NgxEchartsDirective, selector: "echarts, [echarts]", inputs: ["options", "theme", "initOpts", "merge", "autoResize", "loading", "loadingType", "loadingOpts"], outputs: ["chartInit", "optionsError", "chartClick", "chartDblClick", "chartMouseDown", "chartMouseMove", "chartMouseUp", "chartMouseOver", "chartMouseOut", "chartGlobalOut", "chartContextMenu", "chartHighlight", "chartDownplay", "chartSelectChanged", "chartLegendSelectChanged", "chartLegendSelected", "chartLegendUnselected", "chartLegendLegendSelectAll", "chartLegendLegendInverseSelect", "chartLegendScroll", "chartDataZoom", "chartDataRangeSelected", "chartGraphRoam", "chartGeoRoam", "chartTreeRoam", "chartTimelineChanged", "chartTimelinePlayChanged", "chartRestore", "chartDataViewChanged", "chartMagicTypeChanged", "chartGeoSelectChanged", "chartGeoSelected", "chartGeoUnselected", "chartAxisAreaSelected", "chartBrush", "chartBrushEnd", "chartBrushSelected", "chartGlobalCursorTaken", "chartRendered", "chartFinished"], exportAs: ["echarts"] }, { kind: "ngmodule", type: CommonModule }] }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: EChartsComponent, decorators: [{
type: Component,
args: [{ selector: 'app-e-charts', standalone: true, imports: [
NgxEchartsModule,
CommonModule
], providers: [
provideEchartsCore({ echarts }),
], template: "@if (options) {\n<div echarts class=\"chart\" [style.height]=\"resolvedHeight\" [options]=\"options\" [autoResize]=\"true\" [loading]=\"loading\"\n (chartClick)=\"onChartClick($event)\"></div>\n}\n", styles: [":host{display:block;width:100%;min-width:0}.chart{width:100%;min-width:0}\n"] }]
}], propDecorators: { loading: [{
type: Input
}], options: [{
type: Input
}], height: [{
type: Input
}], chartClick: [{
type: Output
}] } });
const colors = [
'#4285F4',
'#FBBC05',
'#34A853',
'#EA4335',
'#55ACEE',
'#3B5998',
'#7CBB00',
'#00A1F1',
'#7B0099',
'#146EB4'
];
function getColors(n) {
const result = [];
for (let i = 0; i < n; i++) {
result.push(colors[i % colors.length]);
}
return result;
}
/**
* Group totals are frequently currency amounts in the millions. Rendering them
* in full turns every axis tick and slice label into a wall of digits, so labels
* use a compact form and the tooltip carries the exact value.
*/
const compactFormatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
});
const exactFormatter = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 2,
});
function formatCompact(value) {
return Number.isFinite(value) ? compactFormatter.format(value) : '—';
}
function formatExact(value) {
return Number.isFinite(value) ? exactFormatter.format(value) : '—';
}
/**
* Every chart in the sheet shares these: the same toolbox affordances, the same
* type ramp, and no chart-level title (the card header already names the chart,
* and an in-canvas title steals vertical space from the plot).
*/
function chartBase(dataSeries) {
return {
color: getColors(dataSeries.length),
textStyle: {
fontFamily: 'Roboto, "Helvetica Neue", sans-serif',
fontSize: 12,
},
toolbox: {
show: true,
right: 8,
top: 0,
itemSize: 14,
feature: {
dataView: { show: true, readOnly: true, title: 'View data' },
restore: { show: true, title: 'Reset' },
saveAsImage: { show: true, title: 'Save image' },
},
},
};
}
/**
* Legends grow with the number of groups; a scrollable bottom legend keeps the
* plot area a constant size no matter how many series land in it.
*/
function scrollingLegend() {
return {
type: 'scroll',
bottom: 0,
left: 'center',
itemWidth: 10,
itemHeight: 10,
itemGap: 12,
textStyle: { fontSize: 11 },
};
}
function createADonutChart(id, title, dataSeries) {
return {
title,
id,
chart: {
...chartBase(dataSeries),
tooltip: {
trigger: 'item',
valueFormatter: (value) => formatExact(Number(value)),
},
legend: scrollingLegend(),
series: [{
name: title,
type: 'pie',
// Percentages, not pixels — the ring has to survive a narrow card.
radius: ['45%', '70%'],
center: ['50%', '45%'],
avoidLabelOverlap: true,
minAngle: 2,
itemStyle: {
borderRadius: 6,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: true,
formatter: (params) => `${formatCompact(Number(params.value))}`,
fontSize: 11,
},
labelLine: {
length: 8,
length2: 8,
lineStyle: { color: '#9aa0a6' },
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold',
},
},
data: dataSeries,
}],
},
};
}
function createABarChart(id, title, dataSeries) {
// Past a handful of categories, horizontal labels collide; tilt them instead
// of letting ECharts silently drop every other one.
const labelRotation = dataSeries.length > 6 ? 35 : 0;
return {
title,
id,
chart: {
...chartBase(dataSeries),
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
valueFormatter: (value) => formatExact(Number(value)),
},
grid: {
top: 24,
left: 8,
right: 8,
bottom: 4,
containLabel: true,
},
xAxis: [{
type: 'category',
data: dataSeries.map(v => v.name),
axisTick: { alignWithLabel: true },
axisLabel: {
fontSize: 11,
rotate: labelRotation,
hideOverlap: true,
width: 96,
overflow: 'truncate',
},
}],
yAxis: [{
type: 'value',
axisLabel: {
fontSize: 11,
formatter: (value) => formatCompact(value),
},
splitLine: { lineStyle: { type: 'dashed', opacity: 0.5 } },
}],
series: [{
name: title,
type: 'bar',
barMaxWidth: 48,
itemStyle: { borderRadius: [4, 4, 0, 0] },
label: {
show: true,
position: 'top',
fontSize: 11,
formatter: (params) => formatCompact(Number(params.value)),
},
emphasis: {
focus: 'series',
label: { show: true, fontSize: 12, fontWeight: 'bold' },
},
data: dataSeries.map((v, index) => ({
...v,
itemStyle: { color: getColors(dataSeries.length)[index] },
})),
}],
},
};
}
function createRoseTypeChart(id, title, dataSeries) {
return {
title,
id,
chart: {
...chartBase(dataSeries),
tooltip: {
trigger: 'item',
valueFormatter: (value) => formatExact(Number(value)),
},
legend: scrollingLegend(),
series: [{
name: title,
type: 'pie',
// Was `[50, 250]` — fixed pixels overflowed any card under ~500px.
radius: ['15%', '72%'],
center: ['50%', '45%'],
roseType: 'area',
itemStyle: { borderRadius: 6 },
label: {
show: true,
fontSize: 11,
formatter: (params) => formatCompact(Number(params.value)),
},
labelLine: { length: 6, length2: 6 },
data: dataSeries,
}],
},
};
}
function createAPolarChart(id, title, dataSeries) {
return {
title,
id,
chart: {
...chartBase(dataSeries),
tooltip: {
trigger: 'item',
valueFormatter: (value) => formatExact(Number(value)),
},
polar: {
radius: ['15%', '72%'],
center: ['50%', '50%'],
},
angleAxis: {
startAngle: 75,
axisLabel: {
fontSize: 11,
formatter: (value) => formatCompact(value),
},
},
radiusAxis: {
type: 'category',
data: dataSeries.map(v => v.name),
axisLabel: { fontSize: 11, width: 96, overflow: 'truncate' },
},
series: [{
type: 'bar',
coordinateSystem: 'polar',
data: dataSeries.map((v, index) => ({
value: v.value,
name: v.name,
itemStyle: { color: getColors(dataSeries.length)[index] },
})),
label: {
show: true,
position: 'middle',
fontSize: 11,
formatter: (params) => `${params.name}: ${formatCompact(Number(params.value))}`,
},
}],
},
};
}
/**
* Column labels arrive from the document configuration in whatever shape the
* form builder produced them — `projectNumber`, `project Number`, `requisition_date`.
* This normalises all of those to clean title case for display only; the
* underlying config (and therefore the Excel export) is left untouched.
*/
const MINOR_WORDS = new Set([
'a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'from', 'in', 'nor',
'of', 'on', 'or', 'per', 'the', 'to', 'via', 'vs', 'with',
]);
class ColumnLabelPipe {
transform(value) {
if (!value)
return '';
const words = String(value)
// split camelCase / PascalCase runs: "projectNumber" -> "project Number"
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
// keep acronym boundaries readable: "GLAccount" -> "GL Account"
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/[_\-.]+/g, ' ')
.trim()
.split(/\s+/)
.filter(Boolean);
return words
.map((word, index) => this.formatWord(word, index === 0 || index === words.length - 1))
.join(' ');
}
formatWord(word, isEdgeWord) {
// Acronyms already carry their own casing — "GL", "VAT", "ID".
if (/^[A-Z0-9]{1,4}$/.test(word))
return word;
const lower = word.toLowerCase();
if (!isEdgeWord && MINOR_WORDS.has(lower))
return lower;
// Long all-caps words are shouting, not acronyms — "REQUISITION" -> "Requisition".
const rest = /^[A-Z]+$/.test(word) ? lower.slice(1) : word.slice(1);
return lower.charAt(0).toUpperCase() + rest;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ColumnLabelPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.12", ngImport: i0, type: ColumnLabelPipe, isStandalone: true, name: "columnLabel" }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ColumnLabelPipe, decorators: [{
type: Pipe,
args: [{
name: 'columnLabel',
standalone: true,
}]
}] });
var ChartTypes;
(function (ChartTypes) {
ChartTypes["Donut"] = "donutChart";
ChartTypes["Bar"] = "bar";
ChartTypes["Polar"] = "polar";
ChartTypes["RoseType"] = "roseType";
})(ChartTypes || (ChartTypes = {}));
/** Compact cards read best around this height; expanded gets room for detail. */
const CARD_CHART_HEIGHT = 260;
const EXPANDED_CHART_HEIGHT = 440;
class GroupingChartComponent {
constructor() {
this.groups = [];
this.numberProperties = [];
/**
* Built once per input change rather than read from a template getter — the
* previous getter hashed the whole group tree with `JSON.stringify` on every
* change-detection pass just to decide it could reuse its cache.
*/
this.charts = [];
this.chartConfig = {};
this.chatTypesEnum = ChartTypes;
}
ngOnChanges(changes) {
if (changes['groups'] || changes['numberProperties']) {
this.buildCharts();
}
}
buildCharts() {
this.charts = (this.numberProperties || []).map((property, index) => {
const id = index.toString();
const dataSeries = (this.groups || []).map(g => ({
name: g.name,
value: Number(g.totals?.[property.formControlName] || 0),
}));
switch (this.chartConfig[id]?.chartType) {
case ChartTypes.Bar: return createABarChart(id, property.label, dataSeries);
case ChartTypes.Polar: return createAPolarChart(id, property.label, dataSeries);
case ChartTypes.RoseType: return createRoseTypeChart(id, property.label, dataSeries);
default: return createADonutChart(id, property.label, dataSeries);
}
});
}
configFor(id) {
if (!this.chartConfig[id]) {
this.chartConfig[id] = { full: false, chartType: ChartTypes.Donut };
}
return this.chartConfig[id];
}
isExpanded(id) {
return !!this.chartConfig[id]?.full;
}
chartTypeOf(id) {
return this.chartConfig[id]?.chartType ?? ChartTypes.Donut;
}
setChartType(id, type) {
this.configFor(id).chartType = type;
this.buildCharts();
}
toggleExpansion(id) {
const config = this.configFor(id);
config.full = !config.full;
}
chartHeight(id) {
return this.isExpanded(id) ? EXPANDED_CHART_HEIGHT : CARD_CHART_HEIGHT;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: GroupingChartComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: GroupingChartComponent, isStandalone: true, selector: "app-grouping-chart", inputs: { groups: "groups", numberProperties: "numberProperties" }, usesOnChanges: true, ngImport: i0, template: "<section class=\"chart-grid\">\n @for (chart of charts; track chart.id) {\n <mat-card class=\"chart-card\" [class.chart-card--full]=\"isExpanded(chart.id)\">\n <div class=\"chart-card-header\">\n <h4 class=\"chart-card-title\">{{ chart.title | columnLabel }}</h4>\n <div class=\"chart-card-actions\">\n <button mat-icon-button class=\"chart-card-action\"\n [matTooltip]=\"isExpanded(chart.id) ? 'Collapse' : 'Expand'\"\n [attr.aria-label]=\"isExpanded(chart.id) ? 'Collapse chart' : 'Expand chart'\"\n (click)=\"toggleExpansion(chart.id)\">\n <mat-icon>{{ isExpanded(chart.id) ? 'close_fullscreen' : 'open_in_full' }}</mat-icon>\n </button>\n <button mat-icon-button class=\"chart-card-action\" [matMenuTriggerFor]=\"menu\"\n [matMenuTriggerData]=\"{ id: chart.id }\" matTooltip=\"Chart type\" aria-label=\"Chart settings\">\n <mat-icon>more_vert</mat-icon>\n </button>\n </div>\n </div>\n <app-e-charts [options]=\"chart.chart\" [height]=\"chartHeight(chart.id)\"></app-e-charts>\n </mat-card>\n }\n</section>\n\n<mat-menu #menu=\"matMenu\">\n <ng-template matMenuContent let-id=\"id\">\n <button (click)=\"setChartType(id, chatTypesEnum.Donut)\" mat-menu-item>\n <mat-icon [color]=\"chartTypeOf(id) === chatTypesEnum.Donut ? 'primary' : ''\">donut_large</mat-icon>\n <span [style.font-weight]=\"chartTypeOf(id) === chatTypesEnum.Donut ? 600 : ''\">Donut</span>\n </button>\n <button (click)=\"setChartType(id, chatTypesEnum.Bar)\" mat-menu-item>\n <mat-icon [color]=\"chartTypeOf(id) === chatTypesEnum.Bar ? 'primary' : ''\">bar_chart</mat-icon>\n <span [style.font-weight]=\"chartTypeOf(id) === chatTypesEnum.Bar ? 600 : ''\">Bar</span>\n </button>\n <button (click)=\"setChartType(id, chatTypesEnum.Polar)\" mat-menu-item>\n <mat-icon [color]=\"chartTypeOf(id) === chatTypesEnum.Polar ? 'primary' : ''\">radar</mat-icon>\n <span [style.font-weight]=\"chartTypeOf(id) === chatTypesEnum.Polar ? 600 : ''\">Polar</span>\n </button>\n <button (click)=\"setChartType(id, chatTypesEnum.RoseType)\" mat-menu-item>\n <mat-icon [color]=\"chartTypeOf(id) === chatTypesEnum.RoseType ? 'primary' : ''\">filter_vintage</mat-icon>\n <span [style.font-weight]=\"chartTypeOf(id) === chatTypesEnum.RoseType ? 600 : ''\">Rose</span>\n </button>\n </ng-template>\n</mat-menu>\n", styles: [".chart-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px;padding:12px;align-items:start}.chart-card{min-width:0;padding:8px 8px 4px;border-radius:12px;box-shadow:none;border:1px solid color-mix(in srgb,var(--mat-sys-outline, #79747e) 18%,transparent);background:var(--mat-sys-surface, #ffffff)}.chart-card--full{grid-column:1/-1}.chart-card-header{display:flex;align-items:center;gap:8px;padding:0 4px 4px;min-height:32px}.chart-card-title{flex:1;min-width:0;margin:0;font-size:.8125rem;font-weight:600;letter-spacing:.01em;color:var(--mat-sys-on-surface, #1c1b1f);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chart-card-actions{display:flex;align-items:center;flex-shrink:0}.chart-card-action{width:28px;height:28px;padding:0;--mat-icon-button-state-layer-size: 28px;opacity:.55;transition:opacity .15s}.chart-card-action .mat-icon{font-size:18px;width:18px;height:18px}.chart-card:hover .chart-card-action,.chart-card-action:focus-visible{opacity:1}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: EChartsComponent, selector: "app-e-charts", inputs: ["loading", "options", "height"], outputs: ["chartClick"] }, { kind: "ngmodule", type: MatCardModule }, { kind: "component", type: i1$1.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i6.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i6$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i4.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i4.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i4.MatMenuContent, selector: "ng-template[matMenuContent]" }, { kind: "directive", type: i4.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i5.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "pipe", type: ColumnLabelPipe, name: "columnLabel" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: GroupingChartComponent, decorators: [{
type: Component,
args: [{ selector: 'app-grouping-chart', standalone: true, imports: [CommonModule, EChartsComponent, MatCardModule, MatButtonModule, MatIconModule, MatMenuModule,
MatTooltipModule, ColumnLabelPipe], changeDetection: ChangeDetectionStrategy.OnPush, template: "<section class=\"chart-grid\">\n @for (chart of charts; track chart.id) {\n <mat-card class=\"chart-card\" [class.chart-card--full]=\"isExpanded(chart.id)\">\n <div class=\"chart-card-header\">\n <h4 class=\"chart-card-title\">{{ chart.title | columnLabel }}</h4>\n <div class=\"chart-card-actions\">\n <button mat-icon-button class=\"chart-card-action\"\n [matTooltip]=\"isExpanded(chart.id) ? 'Collapse' : 'Expand'\"\n [attr.aria-label]=\"isExpanded(chart.id) ? 'Collapse chart' : 'Expand chart'\"\n (click)=\"toggleExpansion(chart.id)\">\n <mat-icon>{{ isExpanded(chart.id) ? 'close_fullscreen' : 'open_in_full' }}</mat-icon>\n </button>\n <button mat-icon-button class=\"chart-card-action\" [matMenuTriggerFor]=\"menu\"\n [matMenuTriggerData]=\"{ id: chart.id }\" matTooltip=\"Chart type\" aria-label=\"Chart settings\">\n <mat-icon>more_vert</mat-icon>\n </button>\n </div>\n </div>\n <app-e-charts [options]=\"chart.chart\" [height]=\"chartHeight(chart.id)\"></app-e-charts>\n </mat-card>\n }\n</section>\n\n<mat-menu #menu=\"matMenu\">\n <ng-template matMenuContent let-id=\"id\">\n <button (click)=\"setChartType(id, chatTypesEnum.Donut)\" mat-menu-item>\n <mat-icon [color]=\"chartTypeOf(id) === chatTypesEnum.Donut ? 'primary' : ''\">donut_large</mat-icon>\n <span [style.font-weight]=\"chartTypeOf(id) === chatTypesEnum.Donut ? 600 : ''\">Donut</span>\n </button>\n <button (click)=\"setChartType(id, chatTypesEnum.Bar)\" mat-menu-item>\n <mat-icon [color]=\"chartTypeOf(id) === chatTypesEnum.Bar ? 'primary' : ''\">bar_chart</mat-icon>\n <span [style.font-weight]=\"chartTypeOf(id) === chatTypesEnum.Bar ? 600 : ''\">Bar</span>\n </button>\n <button (click)=\"setChartType(id, chatTypesEnum.Polar)\" mat-menu-item>\n <mat-icon [color]=\"chartTypeOf(id) === chatTypesEnum.Polar ? 'primary' : ''\">radar</mat-icon>\n <span [style.font-weight]=\"chartTypeOf(id) === chatTypesEnum.Polar ? 600 : ''\">Polar</span>\n </button>\n <button (click)=\"setChartType(id, chatTypesEnum.RoseType)\" mat-menu-item>\n <mat-icon [color]=\"chartTypeOf(id) === chatTypesEnum.RoseType ? 'primary' : ''\">filter_vintage</mat-icon>\n <span [style.font-weight]=\"chartTypeOf(id) === chatTypesEnum.RoseType ? 600 : ''\">Rose</span>\n </button>\n </ng-template>\n</mat-menu>\n", styles: [".chart-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px;padding:12px;align-items:start}.chart-card{min-width:0;padding:8px 8px 4px;border-radius:12px;box-shadow:none;border:1px solid color-mix(in srgb,var(--mat-sys-outline, #79747e) 18%,transparent);background:var(--mat-sys-surface, #ffffff)}.chart-card--full{grid-column:1/-1}.chart-card-header{display:flex;align-items:center;gap:8px;padding:0 4px 4px;min-height:32px}.chart-card-title{flex:1;min-width:0;margin:0;font-size:.8125rem;font-weight:600;letter-spacing:.01em;color:var(--mat-sys-on-surface, #1c1b1f);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chart-card-actions{display:flex;align-items:center;flex-shrink:0}.chart-card-action{width:28px;height:28px;padding:0;--mat-icon-button-state-layer-size: 28px;opacity:.55;transition:opacity .15s}.chart-card-action .mat-icon{font-size:18px;width:18px;height:18px}.chart-card:hover .chart-card-action,.chart-card-action:focus-visible{opacity:1}\n"] }]
}], propDecorators: { groups: [{
type: Input
}], numberProperties: [{
type: Input
}] } });
var SectionTypeGroups;
(function (SectionTypeGroups) {
SectionTypeGroups["Transactions"] = "transactions";
SectionTypeGroups["Processes"] = "processes";
SectionTypeGroups["System"] = "system";
SectionTypeGroups["FormPreview"] = "formPreview";
SectionTypeGroups["Home"] = "home";
SectionTypeGroups["Transaction"] = "transaction";
SectionTypeGroups["Report"] = "Report";
})(SectionTypeGroups || (SectionTypeGroups = {}));
var DocumentLitsLabelConfigInterfaceValueType;
(function (DocumentLitsLabelConfigInterfaceValueType) {
DocumentLitsLabelConfigInterfaceValueType["currency"] = "currency";
DocumentLitsLabelConfigInterfaceValueType["number"] = "number";
DocumentLitsLabelConfigInterfaceValueType["string"] = "string";
DocumentLitsLabelConfigInterfaceValueType["date"] = "date";
DocumentLitsLabelConfigInterfaceValueType["daysAgo"] = "daysAgo";
DocumentLitsLabelConfigInterfaceValueType["systemReference"] = "systemReference";
DocumentLitsLabelConfigInterfaceValueType["boolean"] = "boolean";
})(DocumentLitsLabelConfigInterfaceValueType || (DocumentLitsLabelConfigInterfaceValueType = {}));
function groupData(items, keys, level = 0) {
if (keys.length === 0)
return new Map([[level, items]]); // No more keys, return items wrapped in a Map
const key = keys[0];
if (!key)
return new Map([[level, items]]);
const restKeys = keys.slice(1);
const grouped = new Map();
for (const item of items) {
const keyValue = item[key];
if (!grouped.has(keyValue)) {
grouped.set(keyValue, []);
}
grouped.get(keyValue).push(item);
}
if (restKeys.length > 0) {
grouped.forEach((value, key) => {
grouped.set(key, groupData(value, restKeys, level + 1));
});
}
return grouped;
}
function matrixTableGroupedData(array, propertyName, closedGroups, listConfig) {
// Use a Map to handle grouping which can be more efficient for lookups and updates
const numberProperties = listConfig.filter((config) => config.valueType === 'number'
|| config.valueType === 'currency').map((config) => config.formControlName);
const noneEmptyPropertyNames = propertyName.filter(n => !!n && (n.length > 0));
const noneEmptyArray = array.filter(a => !!a && Object.keys(a).length > 0);
const groupedData = groupData(noneEmptyArray, noneEmptyPropertyNames);
const result = flattenGroupedData(groupedData, propertyName);
return result;
function getSums(data) {
if (!Array.isArray(data)) {
return {};
}
const sums = {
IS_SUM_ROW: true
};
numberProperties.forEach((property) => {
sums[property] = `${data.reduce((acc, item) => acc + Number(item[property] || 0), 0)}`;
});
return sums;
}
function flattenGroupedData(grouped, keys, currentLevel = 0) {
let result = [];
if (keys.length === currentLevel) {
return Array.from(grouped.values()).flat();
}
grouped.forEach((value, key) => {
const isClosed = closedGroups.includes(key);
let _data = [];
_data = flattenGroupedData(value, keys, currentLevel + 1);
const objectProperty = propertyName[currentLevel];
const propertyConfig = listConfig.find(l => l.formControlName === objectProperty);
const groupConfig = {
name: key,
propertyLabel: propertyConfig?.label || objectProperty || '',
IS_GROUP_CONFIG: true,
LEVELS_BEFORE: Array.from({ length: currentLevel }, (_, i) => i + 1),
level: currentLevel,
totals: getSums(_data),
IS_CLOSED: isClosed
};
result.push(groupConfig);
result = result.concat(isClosed ? [] : _data);
});
return result;
}
}
function NgxTMatrixTableStoreSelectors(store) {
const state$ = store.select(state => state);
const reportName$ = store.select(state => state.reportName);
const list$ = store.select(state => state.list);
const listConfig$ = store.select(state => state.listConfig);
const aggregates$ = store.select(state => state.aggregates);
const groupOptions$ = store.select(state => state.groupOptions);
const closedGroups$ = store.select(state => state.closedGroups);
const isOpen$ = store.select(state => state.isOpen);
const groupChart$ = store.select(state => state.groupChart);
const draggedGroup$ = store.select(state => state.draggedGroup);
const scrollIndex$ = store.select(state => state.scrollIndex);
const tableColumns$ = listConfig$;
combineLatest([
groupOptions$,
listConfig$
])
.pipe(map(([groupOptions, listConfig]) => [{ formControlName: 'tree', label: '' }, ...listConfig || []]
.filter((col) => !col.isHidden).map((col) => ({
...col,
INCLUDED_IN_GROUP: Boolean(groupOptions?.columns?.includes(col.formControlName))
}))));
const displayedColumns$ = tableColumns$.pipe(map(tableColumns => tableColumns?.map((config) => config.formControlName) || []));
const groupLabelConfig$ = groupOptions$.pipe(map(groupOptions => groupOptions?.columns?.map((col, index) => ({ level: index, formControlName: index.toString(), label: '' })) || []));
const getFullGroupedData$ = combineLatest([
list$,
listConfig$,
groupOptions$,
closedGroups$
]).pipe(map(([list, listConfig, groupOptions, closedGroups]) => {
const groupBy = groupOptions?.columns || [];
const data = matrixTableGroupedData(list || [], groupBy, closedGroups, listConfig || []);
// This returns the FULL dataset, ungrouped.
return data.filter(d => !(d.name === undefined && d.IS_GROUP_CONFIG));
}),
// Important: Add a shareReplay to prevent recalculation for every new subscriber.
shareReplay(1));
const PAGE_SIZE = 100;
// Derive how many rows should be materialised for the current scroll
// position, then `distinctUntilChanged` on that count. This is the key to
// stopping the scroll bounce: scrolling within a page produces the SAME
// count, so `getGroupedData$` does NOT emit a new array on every scroll
// frame. The table dataSource only changes when the window actually grows
// by a page, which removes the per-frame re-render that fed the loop.
const displayCount$ = combineLatest([
getFullGroupedData$,
scrollIndex$
]).pipe(map(([fullData, scrollIndex]) => {
if (fullData.length === 0) {
return 0;
}
const pages = scrollIndex <= PAGE_SIZE
? 1
: Math.ceil((scrollIndex + 1) / PAGE_SIZE);
// Clamp to the dataset length so the count stabilises (and stops
// emitting) once everything is rendered.
return Math.min(pages * PAGE_SIZE, fullData.length);
}), distinctUntilChanged());
const getGroupedData$ = combineLatest([
getFullGroupedData$, // Use the result of the new selector
displayCount$
]).pipe(
// Now this selector only does a simple, fast slice operation.
map(([fullData, count]) => fullData.slice(0, count)));
const groupedColumns$ = combineLatest([
groupOptions$,
listConfig$
]).pipe(map(([groupOptions, listConfig]) => (groupOptions?.columns?.filter((col) => !groupOptions.hiddenColumns?.includes(col)) || []).map(groupCol => ({
formControlName: groupCol,
label: listConfig?.find((config) => config.formControlName === groupCol)?.label || ''
}))));
return {
state$,
reportName$,
list$,
listConfig$,
closedGroups$,
isOpen$,
aggregates$,
groupOptions$,
tableColumns$,
displayedColumns$,
groupLabelConfig$,
getGroupedData$,
groupedColumns$,
groupChart$,
draggedGroup$,
scrollIndex$,
};
}
function NgxTMatrixTableStoreActions(store) {
return {
setReportName: store.updater((state, reportName) => ({
...state,
reportName
})),
setList: store.updater((state, list) => ({
...state,
list
})),
setListConfig: store.updater((state, listConfig) => ({
...state,
listConfig
})),
setAggregates: store.updater((state, aggregates) => ({
...state,
aggregates
})),
setGroupOptions: store.updater((state, groupOptions) => ({
...state,
groupOptions
})),
toggleMenu: store.updater((state, row) => ({
...state,
isOpen: _isEqual(state.isOpen, row) ? undefined : row
})),
setGroupChart: store.updater((state, groupChart) => ({
...state,
groupChart
})),
setDraggedGroup: store.updater((state, draggedGroup) => ({
...state,
draggedGroup
})),
setScrollIndex: store.updater((state, scrollIndex) => {
return ({
...state,
scrollIndex
});
}),
toggleCloseGroup: store.updater((state, name) => {
const closedGroups = state.closedGroups || [];
const isClosed = closedGroups.includes(name);
return {
...state,
closedGroups: isClosed ? closedGroups.filter(g => g !== name) : [...closedGroups, name]
};
}),
removeGroupColumn: store.updater((state, columnName) => {
return {
...state,
groupOptions: state.groupOptions ? {
...state.groupOptions,
columns: state.groupOptions.columns?.filter((col) => col !== columnName)
} : null
};
}),
dropGroupColumn: store.updater((state) => {
const newGroup = state.draggedGroup;
const doesNotInclude = !state.groupOptions?.columns?.includes(newGroup || '');
if (doesNotInclude && newGroup !== undefined && newGroup !== '') {
return {
...state,
groupOptions: {
...state.groupOptions,
columns: [...(state.groupOptions?.columns || []), newGroup]
}
};
}
return state;
}),
dropGroupingToSortGroups: store.updater((state, event) => {
const newColumns = [...(state.groupOptions?.columns || [])];
moveItemInArray(newColumns, event.previousIndex, event.currentIndex);
return {
...state,
groupOptions: {
...state.groupOptions,
columns: newColumns
}
};
}),
addToGrouping: store.updater((state, formControlName) => {
let columns = state.groupOptions?.columns || [];
if (!columns.includes(formControlName)) {
columns = [...columns, formControlName];
}
else {
columns = columns.filter((col) => col !== formControlName);
}
return {
...state,
groupOptions: {
...state.groupOptions,
columns
}
};
})
};
}
function toCamelCase(arrayOfObjects) {
return arrayOfObjects.map((obj) => {
const newObj = {};
Object.keys(obj).forEach((key) => {
const newKey = ''.concat(key);
const camelCaseKey = camelize(newKey);
newObj[camelCaseKey] = obj[key];
});
return newObj;
});
}
function camelize(str) {
if (!!str) {
return str
.replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) {
return index === 0 ? word.toLowerCase() : word.toUpperCase();
})
.replace(/\s+/g, '');
}
else {
return;
}
}
const getFileName = (name) => {
let timeSpan = new Date().toISOString();
let sheetName = name || 'ExportResult';
let fileName = `${sheetName}-${timeSpan}`;
return {
sheetName,
fileName,
};
};
function triggerDownload(buffer, fileName) {
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function appendJsonSheet(wb, rows, sheetName) {
const sheet = wb.addWorksheet(sheetName);
if (rows.length === 0)
return;
const headers = Object.keys(rows[0] ?? {});
sheet.columns = headers.map(h => ({ header: h, key: h }));
for (const row of rows) {
sheet.addRow(row);
}
}
function worksheetToJson(sheet) {
const headers = [];
const headerRow = sheet.getRow(1);
headerRow.eachCell({ includeEmpty: false }, (cell, colNumber) => {
headers[colNumber - 1] = String(cell.value ?? '');
});
const rows = [];
for (let r = 2; r <= sheet.rowCount; r++) {
const row = sheet.getRow(r);
const obj = {};
let hasValue = false;
for (let c = 1; c <= headers.length; c++) {
const header = headers[c - 1];
if (!header)
continue;
const raw = row.getCell(c).value;
const normalized = normalizeC