UNPKG

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
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