@firestitch/report 18.0.27 → 18.0.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/reports/components/report-canvas/report-canvas.component.d.ts +3 -1
- package/app/reports/components/report-component/report-component.component.d.ts +2 -1
- package/app/reports/data/report.data.d.ts +1 -1
- package/app/reports/dialogs/component-settings/component-settings.component.d.ts +6 -4
- package/app/reports/export/report-export-collector.service.d.ts +2 -0
- package/app/reports/export/report-export.service.d.ts +15 -0
- package/app/reports/interfaces/report.interface.d.ts +4 -0
- package/app/reports/report-filter-items.d.ts +1 -0
- package/app/reports/views/report/fs-ai-report.component.d.ts +1 -3
- package/app/reports/views/report/report.component.d.ts +1 -3
- package/esm2022/app/reports/components/report-canvas/report-canvas.component.mjs +17 -3
- package/esm2022/app/reports/components/report-component/report-component.component.mjs +23 -7
- package/esm2022/app/reports/data/report.data.mjs +8 -3
- package/esm2022/app/reports/dialogs/component-settings/component-settings.component.mjs +10 -11
- package/esm2022/app/reports/export/report-export-collector.service.mjs +63 -18
- package/esm2022/app/reports/export/report-export.service.mjs +77 -0
- package/esm2022/app/reports/export/report-pdf.service.mjs +3 -2
- package/esm2022/app/reports/export/report-pptx.service.mjs +3 -2
- package/esm2022/app/reports/interfaces/report.interface.mjs +1 -1
- package/esm2022/app/reports/report-filter-items.mjs +18 -2
- package/esm2022/app/reports/views/report/fs-ai-report.component.mjs +32 -20
- package/esm2022/app/reports/views/report/report.component.mjs +33 -21
- package/fesm2022/{firestitch-report-echarts-6sMh877A.mjs → firestitch-report-echarts-DeE74U3a.mjs} +2 -2
- package/fesm2022/{firestitch-report-echarts-6sMh877A.mjs.map → firestitch-report-echarts-DeE74U3a.mjs.map} +1 -1
- package/fesm2022/{firestitch-report-firestitch-report-B8LjVTvx.mjs → firestitch-report-firestitch-report-Nxl-C1w0.mjs} +267 -78
- package/fesm2022/firestitch-report-firestitch-report-Nxl-C1w0.mjs.map +1 -0
- package/fesm2022/firestitch-report.mjs +1 -1
- package/package.json +1 -1
- package/fesm2022/firestitch-report-firestitch-report-B8LjVTvx.mjs.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { inject, Injectable, Component, ChangeDetectionStrategy, Input, DestroyRef, ViewChild, EventEmitter, ChangeDetectorRef, NgZone, ElementRef, Output, HostBinding, signal, CUSTOM_ELEMENTS_SCHEMA, model, input, computed, viewChild, effect, output } from '@angular/core';
|
|
2
|
+
import { inject, Injectable, Component, ChangeDetectionStrategy, Input, DestroyRef, ViewChild, EventEmitter, ChangeDetectorRef, NgZone, ElementRef, Output, HostBinding, ViewChildren, signal, CUSTOM_ELEMENTS_SCHEMA, model, input, computed, viewChild, effect, output } from '@angular/core';
|
|
3
3
|
import * as i1$3 from '@angular/forms';
|
|
4
4
|
import { FormsModule, ControlContainer, NgForm } from '@angular/forms';
|
|
5
5
|
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogTitle, MatDialogContent, MatDialogActions, MatDialog, MatDialogClose } from '@angular/material/dialog';
|
|
@@ -13,8 +13,7 @@ import * as i2$3 from '@firestitch/menu';
|
|
|
13
13
|
import { FsMenuModule } from '@firestitch/menu';
|
|
14
14
|
import { FsProcess } from '@firestitch/process';
|
|
15
15
|
import { FsPrompt } from '@firestitch/prompt';
|
|
16
|
-
import {
|
|
17
|
-
import { map, shareReplay, filter, tap, switchMap, catchError, finalize, take } from 'rxjs/operators';
|
|
16
|
+
import { map, shareReplay, filter, tap, switchMap, mergeMap, toArray, catchError, finalize, takeUntil, take } from 'rxjs/operators';
|
|
18
17
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
19
18
|
import { NgxEchartsDirective, provideEchartsCore } from 'ngx-echarts';
|
|
20
19
|
import { FsAiChatComponent } from '@firestitch/ai';
|
|
@@ -27,7 +26,8 @@ import { FsZoomPanComponent, FsZoomPanModule } from '@firestitch/zoom-pan';
|
|
|
27
26
|
import { FsApi, StreamEventType } from '@firestitch/api';
|
|
28
27
|
import * as i1$1 from '@angular/material/menu';
|
|
29
28
|
import { MatMenuModule } from '@angular/material/menu';
|
|
30
|
-
import {
|
|
29
|
+
import { of, Subject, map as map$1, debounceTime, forkJoin, from, EMPTY, BehaviorSubject, switchMap as switchMap$1 } from 'rxjs';
|
|
30
|
+
import { format, parseISO, startOfYear, startOfQuarter, startOfMonth, subMonths, subDays, subWeeks, startOfWeek, endOfWeek, isValid } from 'date-fns';
|
|
31
31
|
import { DatePipe } from '@angular/common';
|
|
32
32
|
import * as i1 from '@firestitch/list';
|
|
33
33
|
import { PaginationStrategy, FsListComponent, FsListModule } from '@firestitch/list';
|
|
@@ -190,8 +190,13 @@ class ReportData {
|
|
|
190
190
|
}
|
|
191
191
|
// One component's data. The body carries everything session-scoped: the
|
|
192
192
|
// resolved filter values and the component's own interaction state.
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
//
|
|
194
|
+
// `isExport` marks the read as part of a PDF/PowerPoint render. The server
|
|
195
|
+
// gives those a longer per-statement ceiling than an on-screen tile, because
|
|
196
|
+
// a reader watching an export progress bar can wait for a query nobody would
|
|
197
|
+
// tolerate waiting for on a dashboard.
|
|
198
|
+
componentData(reportId, componentId, filters = [], state = {}, isExport = false, config = {}) {
|
|
199
|
+
return this._api.post(this._path(`${reportId}/components/${componentId}/data`), { filters, state, export: isExport }, {
|
|
195
200
|
key: 'data',
|
|
196
201
|
...config,
|
|
197
202
|
});
|
|
@@ -443,6 +448,22 @@ function dateBoundString(value) {
|
|
|
443
448
|
// keep just the date portion.
|
|
444
449
|
return value ? String(value).slice(0, 10) : undefined;
|
|
445
450
|
}
|
|
451
|
+
// A date-range boundary as it should READ on a cover page — the same calendar
|
|
452
|
+
// day dateBoundString() hands the server, so a printed period can never name a
|
|
453
|
+
// different day than the numbers under it were filtered by. Returns null when
|
|
454
|
+
// there is no boundary; the caller supplies its own placeholder.
|
|
455
|
+
//
|
|
456
|
+
// The parse has to be parseISO, not `new Date`: `new Date('2026-05-01')` is UTC
|
|
457
|
+
// midnight, so formatting it anywhere west of UTC prints Apr 30. parseISO reads
|
|
458
|
+
// a bare Y-M-D as LOCAL midnight, which no offset can shift.
|
|
459
|
+
function dateBoundLabel(value) {
|
|
460
|
+
const bound = dateBoundString(value);
|
|
461
|
+
if (!bound) {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
const date = parseISO(bound);
|
|
465
|
+
return Number.isNaN(date.getTime()) ? String(value) : format(date, 'MMM d, yyyy');
|
|
466
|
+
}
|
|
446
467
|
// Maps a report filter GROUP to an fs-filter config item, and reads fs-filter
|
|
447
468
|
// query values back into per-group FilterGroupValues. Shared by the report-bar
|
|
448
469
|
// fs-filter and the per-component fs-filter so both render through the same
|
|
@@ -1567,11 +1588,16 @@ class ReportComponentComponent {
|
|
|
1567
1588
|
this._buildFilterConfig();
|
|
1568
1589
|
}
|
|
1569
1590
|
}
|
|
1570
|
-
// fs-filter reads its config only once at init, so the header is keyed on
|
|
1571
|
-
// set of component-level filter groups
|
|
1572
|
-
//
|
|
1591
|
+
// fs-filter reads its config only once at init, so the header is keyed on what
|
|
1592
|
+
// it renders: the set of component-level filter groups AND the title, which
|
|
1593
|
+
// lives in fs-filter's heading template. fs-filter is OnPush and the heading is
|
|
1594
|
+
// rendered inside ITS view, so marking this component does not repaint the
|
|
1595
|
+
// title — recreating the block does.
|
|
1573
1596
|
get filterKey() {
|
|
1574
|
-
return
|
|
1597
|
+
return [
|
|
1598
|
+
this.component.title,
|
|
1599
|
+
...this.componentFilterGroups.map((group) => `${group.id}:${group.level}`),
|
|
1600
|
+
].join('|');
|
|
1575
1601
|
}
|
|
1576
1602
|
get truncated() {
|
|
1577
1603
|
return !!this.data?.truncated;
|
|
@@ -1607,6 +1633,17 @@ class ReportComponentComponent {
|
|
|
1607
1633
|
settings() {
|
|
1608
1634
|
this.openSettings.emit(this.component);
|
|
1609
1635
|
}
|
|
1636
|
+
// Presentation settings saved from the settings dialog. Title and padding are
|
|
1637
|
+
// not part of the query, so the data already fetched still stands — this only
|
|
1638
|
+
// repaints. The header config is rebuilt because `filterKey` changes with the
|
|
1639
|
+
// title and recreates the fs-filter block around it. The chart follows its box
|
|
1640
|
+
// on its own (ngx-echarts observes the host), so nothing is told to resize.
|
|
1641
|
+
applySettings(settings) {
|
|
1642
|
+
this.component.title = settings.title;
|
|
1643
|
+
this.component.config = { ...this.component.config, padding: settings.padding };
|
|
1644
|
+
this._buildFilterConfig();
|
|
1645
|
+
this._cdRef.markForCheck();
|
|
1646
|
+
}
|
|
1610
1647
|
exportCsv() {
|
|
1611
1648
|
const filters = this._filterState.resolveForComponent(this.component);
|
|
1612
1649
|
this._process.download(`Exporting ${this.component.title ?? 'component'}`, this._reportData.exportComponent(this.reportId, this.component.id, filters));
|
|
@@ -1865,7 +1902,7 @@ class ReportComponentComponent {
|
|
|
1865
1902
|
});
|
|
1866
1903
|
}
|
|
1867
1904
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1868
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportComponentComponent, isStandalone: true, selector: "app-report-component", inputs: { reportId: "reportId", component: "component", groups: "groups", editMode: "editMode", layout: "layout", zoom: "zoom", snapper: "snapper", selected: "selected" }, outputs: { selectComponent: "selectComponent", positionChanged: "positionChanged", flowReorder: "flowReorder", openSettings: "openSettings" }, host: { properties: { "class.height-auto": "this.heightAuto" } }, viewQueries: [{ propertyName: "chartComponent", first: true, predicate: ComponentChartComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.selected]=\"selected\"\n [style.padding]=\"bodyPadding\"\n (pointerdown)=\"onComponentPointerDown($event)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (touchstart)=\"stopCanvasPan($event)\">\n <!-- The header is an fs-filter: heading template = title (and the edit-mode\n drag handle), actions = the menu, items = component-level filters.\n Lists carry no items here \u2014 they filter through FsList. fs-filter reads\n its config once, so the block is keyed on the component-filter set to be\n recreated when it changes. -->\n @for (key of [filterKey]; track key) {\n <fs-filter\n class=\"component-filter\"\n [config]=\"filterConfig\">\n <ng-template fsFilterHeading>\n <div\n class=\"component-title\"\n (pointerdown)=\"onDragStart($event)\">\n {{ component.title }}\n @if (truncated) {\n <mat-icon\n class=\"truncated-icon\"\n matTooltip=\"Showing a truncated result \u2014 refine the filters to see everything.\">\n warning_amber\n </mat-icon>\n }\n </div>\n </ng-template>\n </fs-filter>\n }\n <!-- The actions menu is floated top-right (absolute) rather than living in\n fs-filter's actions slot, so its ~40px button height no longer drives the\n header row's height. It overlays the box corner; the heading reserves\n right padding so a long title ellipsises before it. -->\n <button\n mat-icon-button\n class=\"component-menu\"\n [matMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <mat-menu #menu=\"matMenu\">\n <button mat-menu-item (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </button>\n <button mat-menu-item (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </button>\n </mat-menu>\n <div class=\"component-body\">\n @if (component.type === 'list') {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\"\n [groups]=\"groups\">\n </app-report-component-list>\n } @else if (loading) {\n <div class=\"component-state\">\n <div class=\"loading-shimmer\"></div>\n </div>\n } @else if (error) {\n <div class=\"component-state error\">\n {{ error }}\n </div>\n } @else if (!data?.rows?.length) {\n <div class=\"component-state\">\n No data\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-chart>\n }\n <!-- In edit mode a transparent veil over the body makes the WHOLE\n component draggable (industry standard: you grab the object, not just\n its title bar) and keeps inner widgets from swallowing the gesture. -->\n @if (editMode) {\n <div\n class=\"drag-veil\"\n (pointerdown)=\"onDragStart($event)\">\n </div>\n }\n </div>\n</div>\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\n @if (layout === 'freeform') {\n @for (handle of resizeHandles; track handle) {\n <div\n class=\"handle handle-{{ handle }}\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, handle)\">\n </div>\n }\n } @else {\n <!-- Flow height is always auto (the box fits its content), so only the\n east handle (width %) is offered here. -->\n <div\n class=\"handle handle-e\"\n matTooltip=\"Width (% of page)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, 'e')\">\n </div>\n }\n}", styles: [":host{position:absolute;display:flex;flex-direction:column;font-size:10px;--mdc-outlined-button-container-height: 30px;--mdc-outlined-button-label-text-size: 10px;--mat-outlined-button-horizontal-padding: 15px;--mdc-text-button-container-height: 30px;--mdc-text-button-label-text-size: 10px;--mat-text-button-horizontal-padding: 15px}:host.flow-item{position:relative;left:auto;top:auto}:host.flow-dragging{z-index:40;opacity:.7;pointer-events:none}.component-box{position:relative;width:100%;flex:1 1 auto;min-height:0;box-sizing:border-box;display:flex;flex-direction:column;background:#fff;border:1px solid #e4e7eb;border-radius:6px;overflow:hidden;transition:box-shadow .12s ease,border-color .12s ease}.component-box.editing .component-title{cursor:grab}.component-box.editing .component-title:active{cursor:grabbing}.component-box.editing:hover{border-color:#9db3c8;box-shadow:0 2px 8px #0f172a14}.component-box.selected{border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color),0 4px 14px #2196f32e}:host ::ng-deep .component-filter .filter-bar-container{align-items:center!important}.component-filter{flex:0 0 auto;display:block;padding:0 0 4px}.component-filter .component-title{display:flex;align-items:center;gap:4px;-webkit-user-select:none;user-select:none;padding-right:40px;font-size:var(--report-heading-size);font-weight:600;color:#1f2933;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.component-filter .component-title .truncated-icon{font-size:16px;width:16px;height:16px;color:#f59e0b}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}:host(.height-auto) .component-state{min-height:120px}.drag-veil{position:absolute;inset:0;cursor:grab;touch-action:none}.drag-veil:active{cursor:grabbing}.component-state{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:12px;color:#7b8794;padding:8px;text-align:center}.component-state.error{color:#e15759}.loading-shimmer{width:70%;height:60%;border-radius:6px;background:linear-gradient(100deg,#f0f4f8 40%,#e4ebf2,#f0f4f8 60%);background-size:200% 100%;animation:shimmer 1.2s infinite linear}@keyframes shimmer{to{background-position-x:-200%}}.handle{position:absolute;width:14px;height:14px;display:flex;align-items:center;justify-content:center;touch-action:none;z-index:20;transform:scale(calc(1 / var(--canvas-zoom, 1)))}.handle:before{content:\"\";width:8px;height:8px;background:#fff;border:1.5px solid var(--brand-primary-color);border-radius:2px;box-shadow:0 1px 2px #0f172a33}.handle-nw{left:-7px;top:-7px;cursor:nwse-resize}.handle-n{left:calc(50% - 7px);top:-7px;cursor:ns-resize}.handle-ne{right:-7px;top:-7px;cursor:nesw-resize}.handle-e{right:-7px;top:calc(50% - 7px);cursor:ew-resize}.handle-se{right:-7px;bottom:-7px;cursor:nwse-resize}.handle-s{left:calc(50% - 7px);bottom:-7px;cursor:ns-resize}.handle-sw{left:-7px;bottom:-7px;cursor:nesw-resize}.handle-w{left:-7px;top:calc(50% - 7px);cursor:ew-resize}\n"], dependencies: [{ kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i1$1.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: i1$1.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i1$1.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "directive", type: i2.FilterHeadingDirective, selector: "[fsFilterHeading]" }, { kind: "component", type: ComponentChartComponent, selector: "app-report-component-chart", inputs: ["component", "data"] }, { kind: "component", type: ComponentKpiComponent, selector: "app-report-component-kpi", inputs: ["component", "data"] }, { kind: "component", type: ComponentListComponent, selector: "app-report-component-list", inputs: ["reportId", "component", "groups"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1905
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportComponentComponent, isStandalone: true, selector: "app-report-component", inputs: { reportId: "reportId", component: "component", groups: "groups", editMode: "editMode", layout: "layout", zoom: "zoom", snapper: "snapper", selected: "selected" }, outputs: { selectComponent: "selectComponent", positionChanged: "positionChanged", flowReorder: "flowReorder", openSettings: "openSettings" }, host: { properties: { "class.height-auto": "this.heightAuto" } }, viewQueries: [{ propertyName: "chartComponent", first: true, predicate: ComponentChartComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.selected]=\"selected\"\n [style.padding]=\"bodyPadding\"\n (pointerdown)=\"onComponentPointerDown($event)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (touchstart)=\"stopCanvasPan($event)\">\n <!-- The header is an fs-filter: heading template = title (and the edit-mode\n drag handle), actions = the menu, items = component-level filters.\n Lists carry no items here \u2014 they filter through FsList. fs-filter reads\n its config once, so the block is keyed on the component-filter set to be\n recreated when it changes. -->\n @for (key of [filterKey]; track key) {\n <fs-filter\n class=\"component-filter\"\n [config]=\"filterConfig\">\n <ng-template fsFilterHeading>\n <div\n class=\"component-title\"\n (pointerdown)=\"onDragStart($event)\">\n {{ component.title }}\n @if (truncated) {\n <mat-icon\n class=\"truncated-icon\"\n matTooltip=\"Showing a truncated result \u2014 refine the filters to see everything.\">\n warning_amber\n </mat-icon>\n }\n </div>\n </ng-template>\n </fs-filter>\n }\n <!-- The actions menu is floated top-right (absolute) rather than living in\n fs-filter's actions slot, so its ~40px button height no longer drives the\n header row's height. It overlays the box corner; the heading reserves\n right padding so a long title ellipsises before it. -->\n <button\n mat-icon-button\n class=\"component-menu\"\n [matMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <mat-menu #menu=\"matMenu\">\n <button mat-menu-item (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </button>\n <button mat-menu-item (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </button>\n </mat-menu>\n <div class=\"component-body\">\n @if (component.type === 'list') {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\"\n [groups]=\"groups\">\n </app-report-component-list>\n } @else if (loading) {\n <div class=\"component-state\">\n <div class=\"loading-shimmer\"></div>\n </div>\n } @else if (error) {\n <div class=\"component-state error\">\n {{ error }}\n </div>\n } @else if (!data?.rows?.length) {\n <div class=\"component-state\">\n No data\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-chart>\n }\n <!-- In edit mode a transparent veil over the body makes the WHOLE\n component draggable (industry standard: you grab the object, not just\n its title bar) and keeps inner widgets from swallowing the gesture. -->\n @if (editMode) {\n <div\n class=\"drag-veil\"\n (pointerdown)=\"onDragStart($event)\">\n </div>\n }\n </div>\n</div>\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\n @if (layout === 'freeform') {\n @for (handle of resizeHandles; track handle) {\n <div\n class=\"handle handle-{{ handle }}\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, handle)\">\n </div>\n }\n } @else {\n <!-- Flow height is always auto (the box fits its content), so only the\n east handle (width %) is offered here. -->\n <div\n class=\"handle handle-e\"\n matTooltip=\"Width (% of page)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, 'e')\">\n </div>\n }\n}", styles: [":host{position:absolute;display:flex;flex-direction:column;font-size:10px;--mdc-outlined-button-container-height: 30px;--mdc-outlined-button-label-text-size: 10px;--mat-outlined-button-horizontal-padding: 15px;--mdc-text-button-container-height: 30px;--mdc-text-button-label-text-size: 10px;--mat-text-button-horizontal-padding: 15px}:host.flow-item{position:relative;left:auto;top:auto}:host.flow-dragging{z-index:40;opacity:.7;pointer-events:none}.component-box{position:relative;width:100%;flex:1 1 auto;min-height:0;box-sizing:border-box;display:flex;flex-direction:column;background:#fff;border:1px solid #e4e7eb;border-radius:6px;overflow:hidden;transition:box-shadow .12s ease,border-color .12s ease;cursor:default}.component-box.editing .component-title{cursor:grab}.component-box.editing .component-title:active{cursor:grabbing}.component-box.editing:hover{border-color:#9db3c8;box-shadow:0 2px 8px #0f172a14}.component-box.selected{border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color),0 4px 14px #2196f32e}:host ::ng-deep .component-filter .filter-bar-container{align-items:center!important}.component-filter{flex:0 0 auto;display:block;padding:0 0 4px}.component-filter .component-title{display:flex;align-items:center;gap:4px;-webkit-user-select:none;user-select:none;padding-right:40px;font-size:var(--report-heading-size);font-weight:600;color:#1f2933;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.component-filter .component-title .truncated-icon{font-size:16px;width:16px;height:16px;color:#f59e0b}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}:host(.height-auto) .component-state{min-height:120px}.drag-veil{position:absolute;inset:0;cursor:grab;touch-action:none}.drag-veil:active{cursor:grabbing}.component-state{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:12px;color:#7b8794;padding:8px;text-align:center}.component-state.error{color:#e15759}.loading-shimmer{width:70%;height:60%;border-radius:6px;background:linear-gradient(100deg,#f0f4f8 40%,#e4ebf2,#f0f4f8 60%);background-size:200% 100%;animation:shimmer 1.2s infinite linear}@keyframes shimmer{to{background-position-x:-200%}}.handle{position:absolute;width:14px;height:14px;display:flex;align-items:center;justify-content:center;touch-action:none;z-index:20;transform:scale(calc(1 / var(--canvas-zoom, 1)))}.handle:before{content:\"\";width:8px;height:8px;background:#fff;border:1.5px solid var(--brand-primary-color);border-radius:2px;box-shadow:0 1px 2px #0f172a33}.handle-nw{left:-7px;top:-7px;cursor:nwse-resize}.handle-n{left:calc(50% - 7px);top:-7px;cursor:ns-resize}.handle-ne{right:-7px;top:-7px;cursor:nesw-resize}.handle-e{right:-7px;top:calc(50% - 7px);cursor:ew-resize}.handle-se{right:-7px;bottom:-7px;cursor:nwse-resize}.handle-s{left:calc(50% - 7px);bottom:-7px;cursor:ns-resize}.handle-sw{left:-7px;bottom:-7px;cursor:nesw-resize}.handle-w{left:-7px;top:calc(50% - 7px);cursor:ew-resize}\n"], dependencies: [{ kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i1$1.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: i1$1.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i1$1.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "directive", type: i2.FilterHeadingDirective, selector: "[fsFilterHeading]" }, { kind: "component", type: ComponentChartComponent, selector: "app-report-component-chart", inputs: ["component", "data"] }, { kind: "component", type: ComponentKpiComponent, selector: "app-report-component-kpi", inputs: ["component", "data"] }, { kind: "component", type: ComponentListComponent, selector: "app-report-component-list", inputs: ["reportId", "component", "groups"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1869
1906
|
}
|
|
1870
1907
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, decorators: [{
|
|
1871
1908
|
type: Component,
|
|
@@ -1878,7 +1915,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
1878
1915
|
ComponentChartComponent,
|
|
1879
1916
|
ComponentKpiComponent,
|
|
1880
1917
|
ComponentListComponent,
|
|
1881
|
-
], template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.selected]=\"selected\"\n [style.padding]=\"bodyPadding\"\n (pointerdown)=\"onComponentPointerDown($event)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (touchstart)=\"stopCanvasPan($event)\">\n <!-- The header is an fs-filter: heading template = title (and the edit-mode\n drag handle), actions = the menu, items = component-level filters.\n Lists carry no items here \u2014 they filter through FsList. fs-filter reads\n its config once, so the block is keyed on the component-filter set to be\n recreated when it changes. -->\n @for (key of [filterKey]; track key) {\n <fs-filter\n class=\"component-filter\"\n [config]=\"filterConfig\">\n <ng-template fsFilterHeading>\n <div\n class=\"component-title\"\n (pointerdown)=\"onDragStart($event)\">\n {{ component.title }}\n @if (truncated) {\n <mat-icon\n class=\"truncated-icon\"\n matTooltip=\"Showing a truncated result \u2014 refine the filters to see everything.\">\n warning_amber\n </mat-icon>\n }\n </div>\n </ng-template>\n </fs-filter>\n }\n <!-- The actions menu is floated top-right (absolute) rather than living in\n fs-filter's actions slot, so its ~40px button height no longer drives the\n header row's height. It overlays the box corner; the heading reserves\n right padding so a long title ellipsises before it. -->\n <button\n mat-icon-button\n class=\"component-menu\"\n [matMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <mat-menu #menu=\"matMenu\">\n <button mat-menu-item (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </button>\n <button mat-menu-item (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </button>\n </mat-menu>\n <div class=\"component-body\">\n @if (component.type === 'list') {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\"\n [groups]=\"groups\">\n </app-report-component-list>\n } @else if (loading) {\n <div class=\"component-state\">\n <div class=\"loading-shimmer\"></div>\n </div>\n } @else if (error) {\n <div class=\"component-state error\">\n {{ error }}\n </div>\n } @else if (!data?.rows?.length) {\n <div class=\"component-state\">\n No data\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-chart>\n }\n <!-- In edit mode a transparent veil over the body makes the WHOLE\n component draggable (industry standard: you grab the object, not just\n its title bar) and keeps inner widgets from swallowing the gesture. -->\n @if (editMode) {\n <div\n class=\"drag-veil\"\n (pointerdown)=\"onDragStart($event)\">\n </div>\n }\n </div>\n</div>\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\n @if (layout === 'freeform') {\n @for (handle of resizeHandles; track handle) {\n <div\n class=\"handle handle-{{ handle }}\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, handle)\">\n </div>\n }\n } @else {\n <!-- Flow height is always auto (the box fits its content), so only the\n east handle (width %) is offered here. -->\n <div\n class=\"handle handle-e\"\n matTooltip=\"Width (% of page)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, 'e')\">\n </div>\n }\n}", styles: [":host{position:absolute;display:flex;flex-direction:column;font-size:10px;--mdc-outlined-button-container-height: 30px;--mdc-outlined-button-label-text-size: 10px;--mat-outlined-button-horizontal-padding: 15px;--mdc-text-button-container-height: 30px;--mdc-text-button-label-text-size: 10px;--mat-text-button-horizontal-padding: 15px}:host.flow-item{position:relative;left:auto;top:auto}:host.flow-dragging{z-index:40;opacity:.7;pointer-events:none}.component-box{position:relative;width:100%;flex:1 1 auto;min-height:0;box-sizing:border-box;display:flex;flex-direction:column;background:#fff;border:1px solid #e4e7eb;border-radius:6px;overflow:hidden;transition:box-shadow .12s ease,border-color .12s ease}.component-box.editing .component-title{cursor:grab}.component-box.editing .component-title:active{cursor:grabbing}.component-box.editing:hover{border-color:#9db3c8;box-shadow:0 2px 8px #0f172a14}.component-box.selected{border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color),0 4px 14px #2196f32e}:host ::ng-deep .component-filter .filter-bar-container{align-items:center!important}.component-filter{flex:0 0 auto;display:block;padding:0 0 4px}.component-filter .component-title{display:flex;align-items:center;gap:4px;-webkit-user-select:none;user-select:none;padding-right:40px;font-size:var(--report-heading-size);font-weight:600;color:#1f2933;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.component-filter .component-title .truncated-icon{font-size:16px;width:16px;height:16px;color:#f59e0b}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}:host(.height-auto) .component-state{min-height:120px}.drag-veil{position:absolute;inset:0;cursor:grab;touch-action:none}.drag-veil:active{cursor:grabbing}.component-state{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:12px;color:#7b8794;padding:8px;text-align:center}.component-state.error{color:#e15759}.loading-shimmer{width:70%;height:60%;border-radius:6px;background:linear-gradient(100deg,#f0f4f8 40%,#e4ebf2,#f0f4f8 60%);background-size:200% 100%;animation:shimmer 1.2s infinite linear}@keyframes shimmer{to{background-position-x:-200%}}.handle{position:absolute;width:14px;height:14px;display:flex;align-items:center;justify-content:center;touch-action:none;z-index:20;transform:scale(calc(1 / var(--canvas-zoom, 1)))}.handle:before{content:\"\";width:8px;height:8px;background:#fff;border:1.5px solid var(--brand-primary-color);border-radius:2px;box-shadow:0 1px 2px #0f172a33}.handle-nw{left:-7px;top:-7px;cursor:nwse-resize}.handle-n{left:calc(50% - 7px);top:-7px;cursor:ns-resize}.handle-ne{right:-7px;top:-7px;cursor:nesw-resize}.handle-e{right:-7px;top:calc(50% - 7px);cursor:ew-resize}.handle-se{right:-7px;bottom:-7px;cursor:nwse-resize}.handle-s{left:calc(50% - 7px);bottom:-7px;cursor:ns-resize}.handle-sw{left:-7px;bottom:-7px;cursor:nesw-resize}.handle-w{left:-7px;top:calc(50% - 7px);cursor:ew-resize}\n"] }]
|
|
1918
|
+
], template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.selected]=\"selected\"\n [style.padding]=\"bodyPadding\"\n (pointerdown)=\"onComponentPointerDown($event)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (touchstart)=\"stopCanvasPan($event)\">\n <!-- The header is an fs-filter: heading template = title (and the edit-mode\n drag handle), actions = the menu, items = component-level filters.\n Lists carry no items here \u2014 they filter through FsList. fs-filter reads\n its config once, so the block is keyed on the component-filter set to be\n recreated when it changes. -->\n @for (key of [filterKey]; track key) {\n <fs-filter\n class=\"component-filter\"\n [config]=\"filterConfig\">\n <ng-template fsFilterHeading>\n <div\n class=\"component-title\"\n (pointerdown)=\"onDragStart($event)\">\n {{ component.title }}\n @if (truncated) {\n <mat-icon\n class=\"truncated-icon\"\n matTooltip=\"Showing a truncated result \u2014 refine the filters to see everything.\">\n warning_amber\n </mat-icon>\n }\n </div>\n </ng-template>\n </fs-filter>\n }\n <!-- The actions menu is floated top-right (absolute) rather than living in\n fs-filter's actions slot, so its ~40px button height no longer drives the\n header row's height. It overlays the box corner; the heading reserves\n right padding so a long title ellipsises before it. -->\n <button\n mat-icon-button\n class=\"component-menu\"\n [matMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <mat-menu #menu=\"matMenu\">\n <button mat-menu-item (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </button>\n <button mat-menu-item (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </button>\n </mat-menu>\n <div class=\"component-body\">\n @if (component.type === 'list') {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\"\n [groups]=\"groups\">\n </app-report-component-list>\n } @else if (loading) {\n <div class=\"component-state\">\n <div class=\"loading-shimmer\"></div>\n </div>\n } @else if (error) {\n <div class=\"component-state error\">\n {{ error }}\n </div>\n } @else if (!data?.rows?.length) {\n <div class=\"component-state\">\n No data\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-chart>\n }\n <!-- In edit mode a transparent veil over the body makes the WHOLE\n component draggable (industry standard: you grab the object, not just\n its title bar) and keeps inner widgets from swallowing the gesture. -->\n @if (editMode) {\n <div\n class=\"drag-veil\"\n (pointerdown)=\"onDragStart($event)\">\n </div>\n }\n </div>\n</div>\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\n @if (layout === 'freeform') {\n @for (handle of resizeHandles; track handle) {\n <div\n class=\"handle handle-{{ handle }}\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, handle)\">\n </div>\n }\n } @else {\n <!-- Flow height is always auto (the box fits its content), so only the\n east handle (width %) is offered here. -->\n <div\n class=\"handle handle-e\"\n matTooltip=\"Width (% of page)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, 'e')\">\n </div>\n }\n}", styles: [":host{position:absolute;display:flex;flex-direction:column;font-size:10px;--mdc-outlined-button-container-height: 30px;--mdc-outlined-button-label-text-size: 10px;--mat-outlined-button-horizontal-padding: 15px;--mdc-text-button-container-height: 30px;--mdc-text-button-label-text-size: 10px;--mat-text-button-horizontal-padding: 15px}:host.flow-item{position:relative;left:auto;top:auto}:host.flow-dragging{z-index:40;opacity:.7;pointer-events:none}.component-box{position:relative;width:100%;flex:1 1 auto;min-height:0;box-sizing:border-box;display:flex;flex-direction:column;background:#fff;border:1px solid #e4e7eb;border-radius:6px;overflow:hidden;transition:box-shadow .12s ease,border-color .12s ease;cursor:default}.component-box.editing .component-title{cursor:grab}.component-box.editing .component-title:active{cursor:grabbing}.component-box.editing:hover{border-color:#9db3c8;box-shadow:0 2px 8px #0f172a14}.component-box.selected{border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color),0 4px 14px #2196f32e}:host ::ng-deep .component-filter .filter-bar-container{align-items:center!important}.component-filter{flex:0 0 auto;display:block;padding:0 0 4px}.component-filter .component-title{display:flex;align-items:center;gap:4px;-webkit-user-select:none;user-select:none;padding-right:40px;font-size:var(--report-heading-size);font-weight:600;color:#1f2933;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.component-filter .component-title .truncated-icon{font-size:16px;width:16px;height:16px;color:#f59e0b}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}:host(.height-auto) .component-state{min-height:120px}.drag-veil{position:absolute;inset:0;cursor:grab;touch-action:none}.drag-veil:active{cursor:grabbing}.component-state{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:12px;color:#7b8794;padding:8px;text-align:center}.component-state.error{color:#e15759}.loading-shimmer{width:70%;height:60%;border-radius:6px;background:linear-gradient(100deg,#f0f4f8 40%,#e4ebf2,#f0f4f8 60%);background-size:200% 100%;animation:shimmer 1.2s infinite linear}@keyframes shimmer{to{background-position-x:-200%}}.handle{position:absolute;width:14px;height:14px;display:flex;align-items:center;justify-content:center;touch-action:none;z-index:20;transform:scale(calc(1 / var(--canvas-zoom, 1)))}.handle:before{content:\"\";width:8px;height:8px;background:#fff;border:1.5px solid var(--brand-primary-color);border-radius:2px;box-shadow:0 1px 2px #0f172a33}.handle-nw{left:-7px;top:-7px;cursor:nwse-resize}.handle-n{left:calc(50% - 7px);top:-7px;cursor:ns-resize}.handle-ne{right:-7px;top:-7px;cursor:nesw-resize}.handle-e{right:-7px;top:calc(50% - 7px);cursor:ew-resize}.handle-se{right:-7px;bottom:-7px;cursor:nwse-resize}.handle-s{left:calc(50% - 7px);bottom:-7px;cursor:ns-resize}.handle-sw{left:-7px;bottom:-7px;cursor:nesw-resize}.handle-w{left:-7px;top:calc(50% - 7px);cursor:ew-resize}\n"] }]
|
|
1882
1919
|
}], propDecorators: { reportId: [{
|
|
1883
1920
|
type: Input
|
|
1884
1921
|
}], component: [{
|
|
@@ -1936,6 +1973,9 @@ class ReportCanvasComponent {
|
|
|
1936
1973
|
// editMode flag, so it flips it off (mirrors the menu's "Done editing layout").
|
|
1937
1974
|
editDone = new EventEmitter();
|
|
1938
1975
|
zoomPan;
|
|
1976
|
+
// The rendered components of the active page — only the canvas can reach the
|
|
1977
|
+
// one instance a settings save applies to.
|
|
1978
|
+
_renderedComponents;
|
|
1939
1979
|
_viewport;
|
|
1940
1980
|
_guides;
|
|
1941
1981
|
PX = PX_PER_INCH;
|
|
@@ -2006,6 +2046,14 @@ class ReportCanvasComponent {
|
|
|
2006
2046
|
pageLabel(page, index) {
|
|
2007
2047
|
return page.name || `Page ${index + 1}`;
|
|
2008
2048
|
}
|
|
2049
|
+
// Hand presentation settings (title, padding) saved in the settings dialog to
|
|
2050
|
+
// the one component they belong to. Nothing here feeds the query, so that
|
|
2051
|
+
// component repaints and every other one on the page is left alone.
|
|
2052
|
+
applyComponentSettings(componentId, settings) {
|
|
2053
|
+
this._renderedComponents
|
|
2054
|
+
.find((rendered) => rendered.component.id === componentId)
|
|
2055
|
+
?.applySettings(settings);
|
|
2056
|
+
}
|
|
2009
2057
|
selectComponent(componentId) {
|
|
2010
2058
|
this.selectedComponentId = componentId;
|
|
2011
2059
|
this._cdRef.markForCheck();
|
|
@@ -2215,7 +2263,7 @@ class ReportCanvasComponent {
|
|
|
2215
2263
|
this._guides?.nativeElement?.replaceChildren();
|
|
2216
2264
|
}
|
|
2217
2265
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportCanvasComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2218
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportCanvasComponent, isStandalone: true, selector: "app-report-canvas", inputs: { report: "report", editMode: "editMode" }, outputs: { componentSettings: "componentSettings", reportChanged: "reportChanged", editDone: "editDone" }, viewQueries: [{ propertyName: "zoomPan", first: true, predicate: FsZoomPanComponent, descendants: true }, { propertyName: "_viewport", first: true, predicate: ["viewport"], descendants: true, static: true }, { propertyName: "_guides", first: true, predicate: ["guides"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"canvas\">\n <div\n #viewport\n class=\"page-viewport\"\n (pointerdown)=\"onCanvasPointerDown()\">\n <fs-zoom-pan\n [zoomMin]=\"0.1\"\n [zoomMax]=\"3\"\n (zoomed)=\"onZoomed($event)\">\n @if (activePage; as page) {\n <div\n class=\"page\"\n [class.flow]=\"isFlow\"\n [class.editing]=\"editMode\"\n [style.width.px]=\"report.pageWidth * PX\"\n [style.height.px]=\"report.pageHeight * PX\"\n [style.padding.px]=\"isFlow ? flowPadding * PX : 0\"\n [style.--canvas-zoom]=\"zoom\"\n [style.--report-heading-size.px]=\"headingSizePx\">\n @if (isFlow) {\n @for (component of flowComponents; track component.id) {\n <app-report-component\n class=\"flow-item\"\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"editMode\"\n [layout]=\"'flow'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [selected]=\"component.id === selectedComponentId\"\n [style.width.%]=\"component.flowWidth\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (flowReorder)=\"onFlowReorder($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n } @else {\n @for (component of page.components; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"editMode\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [selected]=\"component.id === selectedComponentId\"\n [style.left.px]=\"component.x * PX\"\n [style.top.px]=\"component.y * PX\"\n [style.width.px]=\"component.w * PX\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n }\n\n <div\n #guides\n class=\"guides\">\n </div>\n\n @if (!page.components.length) {\n <div class=\"page-empty\">\n Ask the assistant to add a chart, list or KPI to this page.\n </div>\n }\n </div>\n }\n </fs-zoom-pan>\n\n @if (editMode) {\n <button\n mat-flat-button\n type=\"button\"\n color=\"primary\"\n class=\"edit-done\"\n matTooltip=\"Exit layout editing\"\n (click)=\"editDone.emit()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n }\n </div>\n\n <div class=\"canvas-toolbar\">\n <div class=\"page-tabs\">\n @if ((report?.pages?.length ?? 0) > 1) {\n @for (page of report.pages; track page.id; let index = $index) {\n <button\n type=\"button\"\n class=\"page-tab\"\n [class.active]=\"index === activePageIndex\"\n (click)=\"selectPage(index)\">\n {{ pageLabel(page, index) }}\n </button>\n }\n }\n <button\n type=\"button\"\n class=\"page-tab add-page\"\n matTooltip=\"Add page\"\n (click)=\"addPage()\">\n <mat-icon>add</mat-icon>\n Page\n </button>\n </div>\n\n <div class=\"zoom-controls\">\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom out\"\n (click)=\"zoomOut()\">\n <mat-icon>remove</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-label\"\n matTooltip=\"Zoom to 100%\"\n (click)=\"zoomActual()\">\n {{ (zoom * 100).toFixed(0) }}%\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom in\"\n (click)=\"zoomIn()\">\n <mat-icon>add</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Fit page\"\n (click)=\"zoomFit()\">\n <mat-icon>fit_screen</mat-icon>\n </button>\n </div>\n </div>\n</div>\n", styles: [":host{display:flex;flex-direction:column;min-width:0;min-height:0;flex:1;margin-top:10px}.canvas{display:flex;flex-direction:column;gap:8px;flex:1;min-height:0}.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px}.canvas-toolbar .page-tabs{display:flex;gap:4px;flex-wrap:wrap}.canvas-toolbar .page-tabs .page-tab{border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 14px;font-size:12px;color:#52606d;cursor:pointer}.canvas-toolbar .page-tabs .page-tab.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.canvas-toolbar .page-tabs .page-tab.add-page{display:inline-flex;align-items:center;gap:2px;padding-left:8px;border-style:dashed;color:#52606d}.canvas-toolbar .page-tabs .page-tab.add-page mat-icon{font-size:16px;width:16px;height:16px}.canvas-toolbar .zoom-controls{display:flex;align-items:center;gap:2px;margin-left:auto;border:1px solid #e4e7eb;border-radius:18px;background:#fff;padding:2px 6px}.canvas-toolbar .zoom-controls .zoom-button{display:flex;align-items:center;justify-content:center;border:none;border-radius:50%;background:transparent;color:#52606d;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-button:hover{background:#f0f4f8}.canvas-toolbar .zoom-controls .zoom-button mat-icon{font-size:18px;width:18px;height:18px}.canvas-toolbar .zoom-controls .zoom-label{border:none;background:transparent;font-size:12px;color:#52606d;min-width:44px;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-label:hover{color:#1f2933}.page-viewport{position:relative;flex:1;min-height:420px;border:1px solid #e4e7eb;border-radius:8px;background:#eef1f5;overflow:hidden}.page-viewport fs-zoom-pan{width:100%;height:100%}.edit-done{position:absolute;top:12px;right:12px;z-index:40;border-radius:999px;box-shadow:0 2px 10px #0f172a2e}.page{position:relative;background:#fff;border-radius:2px;box-shadow:0 2px 12px #0f172a1f,0 0 0 1px #0f172a0a;box-sizing:border-box}.page.flow{display:flex;flex-wrap:wrap;align-content:flex-start;align-items:flex-start}.page.editing{background-image:radial-gradient(circle,#d8dee6 1px,transparent 1px);background-size:24px 24px}.guides{position:absolute;inset:0;pointer-events:none;z-index:30}.guides ::ng-deep .guide{position:absolute;background:#2196f3}.guides ::ng-deep .guide.guide-v{top:0;bottom:0;width:calc(1.25px / var(--canvas-zoom, 1))}.guides ::ng-deep .guide.guide-h{left:0;right:0;height:calc(1.25px / var(--canvas-zoom, 1))}.page-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#9aa5b1;font-size:14px;pointer-events:none}\n"], dependencies: [{ kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsZoomPanModule }, { kind: "component", type: i1$2.FsZoomPanComponent, selector: "fs-zoom-pan", inputs: ["zoomMax", "zoomMin", "zoomScale", "zoomDefault", "zoomFactor", "top", "left"], outputs: ["moved", "zoomed"] }, { kind: "component", type: ReportComponentComponent, selector: "app-report-component", inputs: ["reportId", "component", "groups", "editMode", "layout", "zoom", "snapper", "selected"], outputs: ["selectComponent", "positionChanged", "flowReorder", "openSettings"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2266
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportCanvasComponent, isStandalone: true, selector: "app-report-canvas", inputs: { report: "report", editMode: "editMode" }, outputs: { componentSettings: "componentSettings", reportChanged: "reportChanged", editDone: "editDone" }, viewQueries: [{ propertyName: "zoomPan", first: true, predicate: FsZoomPanComponent, descendants: true }, { propertyName: "_viewport", first: true, predicate: ["viewport"], descendants: true, static: true }, { propertyName: "_guides", first: true, predicate: ["guides"], descendants: true }, { propertyName: "_renderedComponents", predicate: ReportComponentComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"canvas\">\n <div\n #viewport\n class=\"page-viewport\"\n (pointerdown)=\"onCanvasPointerDown()\">\n <fs-zoom-pan\n [zoomMin]=\"0.1\"\n [zoomMax]=\"3\"\n (zoomed)=\"onZoomed($event)\">\n @if (activePage; as page) {\n <div\n class=\"page\"\n [class.flow]=\"isFlow\"\n [class.editing]=\"editMode\"\n [style.width.px]=\"report.pageWidth * PX\"\n [style.height.px]=\"report.pageHeight * PX\"\n [style.padding.px]=\"isFlow ? flowPadding * PX : 0\"\n [style.--canvas-zoom]=\"zoom\"\n [style.--report-heading-size.px]=\"headingSizePx\">\n @if (isFlow) {\n @for (component of flowComponents; track component.id) {\n <app-report-component\n class=\"flow-item\"\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"editMode\"\n [layout]=\"'flow'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [selected]=\"component.id === selectedComponentId\"\n [style.width.%]=\"component.flowWidth\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (flowReorder)=\"onFlowReorder($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n } @else {\n @for (component of page.components; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"editMode\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [selected]=\"component.id === selectedComponentId\"\n [style.left.px]=\"component.x * PX\"\n [style.top.px]=\"component.y * PX\"\n [style.width.px]=\"component.w * PX\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n }\n\n <div\n #guides\n class=\"guides\">\n </div>\n\n @if (!page.components.length) {\n <div class=\"page-empty\">\n Ask the assistant to add a chart, list or KPI to this page.\n </div>\n }\n </div>\n }\n </fs-zoom-pan>\n\n @if (editMode) {\n <button\n mat-flat-button\n type=\"button\"\n color=\"primary\"\n class=\"edit-done\"\n matTooltip=\"Exit layout editing\"\n (click)=\"editDone.emit()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n }\n </div>\n\n <div class=\"canvas-toolbar\">\n <div class=\"page-tabs\">\n @if ((report?.pages?.length ?? 0) > 1) {\n @for (page of report.pages; track page.id; let index = $index) {\n <button\n type=\"button\"\n class=\"page-tab\"\n [class.active]=\"index === activePageIndex\"\n (click)=\"selectPage(index)\">\n {{ pageLabel(page, index) }}\n </button>\n }\n }\n <button\n type=\"button\"\n class=\"page-tab add-page\"\n matTooltip=\"Add page\"\n (click)=\"addPage()\">\n <mat-icon>add</mat-icon>\n Page\n </button>\n </div>\n\n <div class=\"zoom-controls\">\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom out\"\n (click)=\"zoomOut()\">\n <mat-icon>remove</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-label\"\n matTooltip=\"Zoom to 100%\"\n (click)=\"zoomActual()\">\n {{ (zoom * 100).toFixed(0) }}%\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom in\"\n (click)=\"zoomIn()\">\n <mat-icon>add</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Fit page\"\n (click)=\"zoomFit()\">\n <mat-icon>fit_screen</mat-icon>\n </button>\n </div>\n </div>\n</div>\n", styles: [":host{display:flex;flex-direction:column;min-width:0;min-height:0;flex:1;margin-top:10px}.canvas{display:flex;flex-direction:column;gap:8px;flex:1;min-height:0}.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px}.canvas-toolbar .page-tabs{display:flex;gap:4px;flex-wrap:wrap}.canvas-toolbar .page-tabs .page-tab{border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 14px;font-size:12px;color:#52606d;cursor:pointer}.canvas-toolbar .page-tabs .page-tab.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.canvas-toolbar .page-tabs .page-tab.add-page{display:inline-flex;align-items:center;gap:2px;padding-left:8px;border-style:dashed;color:#52606d}.canvas-toolbar .page-tabs .page-tab.add-page mat-icon{font-size:16px;width:16px;height:16px}.canvas-toolbar .zoom-controls{display:flex;align-items:center;gap:2px;margin-left:auto;border:1px solid #e4e7eb;border-radius:18px;background:#fff;padding:2px 6px}.canvas-toolbar .zoom-controls .zoom-button{display:flex;align-items:center;justify-content:center;border:none;border-radius:50%;background:transparent;color:#52606d;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-button:hover{background:#f0f4f8}.canvas-toolbar .zoom-controls .zoom-button mat-icon{font-size:18px;width:18px;height:18px}.canvas-toolbar .zoom-controls .zoom-label{border:none;background:transparent;font-size:12px;color:#52606d;min-width:44px;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-label:hover{color:#1f2933}.page-viewport{position:relative;flex:1;min-height:420px;border:1px solid #e4e7eb;border-radius:8px;background:#eef1f5;overflow:hidden}.page-viewport fs-zoom-pan{width:100%;height:100%}.edit-done{position:absolute;top:12px;right:12px;z-index:40;border-radius:999px;box-shadow:0 2px 10px #0f172a2e}.page{position:relative;background:#fff;border-radius:2px;box-shadow:0 2px 12px #0f172a1f,0 0 0 1px #0f172a0a;box-sizing:border-box}.page.flow{display:flex;flex-wrap:wrap;align-content:flex-start;align-items:flex-start}.page.editing{background-image:radial-gradient(circle,#d8dee6 1px,transparent 1px);background-size:24px 24px}.guides{position:absolute;inset:0;pointer-events:none;z-index:30}.guides ::ng-deep .guide{position:absolute;background:#2196f3}.guides ::ng-deep .guide.guide-v{top:0;bottom:0;width:calc(1.25px / var(--canvas-zoom, 1))}.guides ::ng-deep .guide.guide-h{left:0;right:0;height:calc(1.25px / var(--canvas-zoom, 1))}.page-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#9aa5b1;font-size:14px;pointer-events:none}\n"], dependencies: [{ kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsZoomPanModule }, { kind: "component", type: i1$2.FsZoomPanComponent, selector: "fs-zoom-pan", inputs: ["zoomMax", "zoomMin", "zoomScale", "zoomDefault", "zoomFactor", "top", "left"], outputs: ["moved", "zoomed"] }, { kind: "component", type: ReportComponentComponent, selector: "app-report-component", inputs: ["reportId", "component", "groups", "editMode", "layout", "zoom", "snapper", "selected"], outputs: ["selectComponent", "positionChanged", "flowReorder", "openSettings"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2219
2267
|
}
|
|
2220
2268
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportCanvasComponent, decorators: [{
|
|
2221
2269
|
type: Component,
|
|
@@ -2239,6 +2287,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
2239
2287
|
}], zoomPan: [{
|
|
2240
2288
|
type: ViewChild,
|
|
2241
2289
|
args: [FsZoomPanComponent]
|
|
2290
|
+
}], _renderedComponents: [{
|
|
2291
|
+
type: ViewChildren,
|
|
2292
|
+
args: [ReportComponentComponent]
|
|
2242
2293
|
}], _viewport: [{
|
|
2243
2294
|
type: ViewChild,
|
|
2244
2295
|
args: ['viewport', { static: true }]
|
|
@@ -2257,6 +2308,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
2257
2308
|
// immediately and refreshes the in-dialog list.
|
|
2258
2309
|
// SQL / Config — read-only diagnostics.
|
|
2259
2310
|
class ComponentSettingsComponent {
|
|
2311
|
+
// A filter was added, removed or re-levelled, so the report's filter groups
|
|
2312
|
+
// changed. Those mutations persist the moment they are clicked and the dialog
|
|
2313
|
+
// can then be dismissed by a route that carries no result (Escape, backdrop),
|
|
2314
|
+
// so the page is told here rather than through the close result.
|
|
2315
|
+
filtersChanged = new EventEmitter();
|
|
2260
2316
|
component;
|
|
2261
2317
|
report;
|
|
2262
2318
|
selectedTab = 'settings';
|
|
@@ -2281,9 +2337,6 @@ class ComponentSettingsComponent {
|
|
|
2281
2337
|
dateRange: 'Date range',
|
|
2282
2338
|
keyword: 'Search text',
|
|
2283
2339
|
};
|
|
2284
|
-
// True once any filter mutation happened — the parent reloads the report so
|
|
2285
|
-
// the canvas + report bar reflect the change.
|
|
2286
|
-
_changed = false;
|
|
2287
2340
|
_data = inject(MAT_DIALOG_DATA);
|
|
2288
2341
|
_dialogRef = inject(MatDialogRef);
|
|
2289
2342
|
_reportData = inject(ReportData);
|
|
@@ -2302,9 +2355,6 @@ class ComponentSettingsComponent {
|
|
|
2302
2355
|
this._seedFilters(this.report, this.component.id);
|
|
2303
2356
|
this._loadColumns();
|
|
2304
2357
|
}
|
|
2305
|
-
get hasChanges() {
|
|
2306
|
-
return this._changed;
|
|
2307
|
-
}
|
|
2308
2358
|
// Human-friendly name for the inferred interface shown beside each column.
|
|
2309
2359
|
typeLabel(type) {
|
|
2310
2360
|
return this._typeLabels[type] ?? type;
|
|
@@ -2319,10 +2369,10 @@ class ComponentSettingsComponent {
|
|
|
2319
2369
|
left: this.paddingLeft,
|
|
2320
2370
|
},
|
|
2321
2371
|
};
|
|
2322
|
-
return this._reportData.updateComponent(this.report.id, this.component.id, settings)
|
|
2372
|
+
return this._reportData.updateComponent(this.report.id, this.component.id, { ...settings })
|
|
2323
2373
|
.pipe(tap(() => {
|
|
2324
2374
|
this._message.success('Component saved');
|
|
2325
|
-
this._dialogRef.close(
|
|
2375
|
+
this._dialogRef.close({ settings });
|
|
2326
2376
|
}));
|
|
2327
2377
|
};
|
|
2328
2378
|
// Flip one exposure (report bar or this component) for a row. We update the
|
|
@@ -2380,7 +2430,7 @@ class ComponentSettingsComponent {
|
|
|
2380
2430
|
// After any filter mutation: re-fetch the report so the dialog's filter list
|
|
2381
2431
|
// (and the parent canvas, on close) reflect the new groups/levels.
|
|
2382
2432
|
_afterMutation() {
|
|
2383
|
-
this.
|
|
2433
|
+
this.filtersChanged.emit();
|
|
2384
2434
|
this._reportData.get(this.report.id)
|
|
2385
2435
|
.subscribe((report) => {
|
|
2386
2436
|
this.report = report;
|
|
@@ -2732,29 +2782,74 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
2732
2782
|
// Rows fetched for a list component's export table — enough to fill a
|
|
2733
2783
|
// slide/page table; the cap note covers the rest.
|
|
2734
2784
|
const LIST_EXPORT_ROWS = 100;
|
|
2785
|
+
// How many tile queries an export runs at once.
|
|
2786
|
+
//
|
|
2787
|
+
// Not a politeness setting — it is the difference between a deck and a broken
|
|
2788
|
+
// one. These queries are heavy and read the same tables, so firing all of them
|
|
2789
|
+
// together puts every one in contention for the same database at the same
|
|
2790
|
+
// moment and they cross the statement ceiling as a group: measured on report
|
|
2791
|
+
// 28, ten tiles that each answer in 9-24s run one after another all failed at
|
|
2792
|
+
// ~30s when released together. Concurrency buys wall-clock only up to the point
|
|
2793
|
+
// where the queries start taking it back off each other, and past that it buys
|
|
2794
|
+
// failures.
|
|
2795
|
+
const EXPORT_CONCURRENCY = 3;
|
|
2796
|
+
// What a component with nothing to draw prints in the exported file.
|
|
2797
|
+
//
|
|
2798
|
+
// The two cases must not read alike. A tile that returned no rows is a finding
|
|
2799
|
+
// about the period — the report worked and the answer is "none". A tile whose
|
|
2800
|
+
// query failed is a hole in the file, and calling that "Data unavailable" tells
|
|
2801
|
+
// the reader the data does not exist when what happened is that nobody managed
|
|
2802
|
+
// to ask for it. Shared by both renderers so the PDF and the deck cannot end up
|
|
2803
|
+
// describing the same hole two different ways.
|
|
2804
|
+
function emptyLabel(item) {
|
|
2805
|
+
return item.data ? 'No data' : 'Could not be generated';
|
|
2806
|
+
}
|
|
2807
|
+
// The pages of a collected report that have at least one tile whose query
|
|
2808
|
+
// failed — what the export gate names to the user, and what makes "some of this
|
|
2809
|
+
// file is missing" a fact the caller can act on rather than a thing they notice
|
|
2810
|
+
// in the PDF afterwards.
|
|
2811
|
+
function failedPageNames(collected) {
|
|
2812
|
+
// Numbered before the filter — an unnamed page 4 that failed has to read
|
|
2813
|
+
// "Page 4", not "Page 1" because it happened to be the first failure.
|
|
2814
|
+
return collected.pages
|
|
2815
|
+
.map((page, index) => ({ page, number: index + 1 }))
|
|
2816
|
+
.filter(({ page }) => page.components.some((item) => !item.data))
|
|
2817
|
+
.map(({ page, number }) => page.page.name || `Page ${number}`);
|
|
2818
|
+
}
|
|
2735
2819
|
// Gathers everything an export needs: every component's data across ALL pages
|
|
2736
2820
|
// (the canvas only renders the active page, so exports can't scrape the
|
|
2737
|
-
// screen), fetched fresh with the viewer's current filter state.
|
|
2738
|
-
//
|
|
2821
|
+
// screen), fetched fresh with the viewer's current filter state.
|
|
2822
|
+
//
|
|
2823
|
+
// A failed component resolves to a null `data` rather than failing the whole
|
|
2824
|
+
// collection — but that is so the caller can REPORT what failed, not so it can
|
|
2825
|
+
// be ignored: see failedPageNames() and the export gate that uses it.
|
|
2739
2826
|
class ReportExportCollectorService {
|
|
2740
2827
|
_reportData = inject(ReportData);
|
|
2741
2828
|
_filterState = inject(ReportFilterStateService);
|
|
2742
2829
|
collect(report) {
|
|
2743
|
-
const requests = [];
|
|
2744
2830
|
const slots = [];
|
|
2745
2831
|
report.pages.forEach((page, pageIndex) => {
|
|
2746
2832
|
for (const component of page.components) {
|
|
2747
|
-
slots.push({ pageIndex });
|
|
2748
|
-
requests.push(this._componentData(report, component));
|
|
2833
|
+
slots.push({ pageIndex, component });
|
|
2749
2834
|
}
|
|
2750
2835
|
});
|
|
2751
|
-
|
|
2836
|
+
// Released a few at a time rather than all at once. mergeMap completes out
|
|
2837
|
+
// of order, so each result carries the slot it came from and the rows are
|
|
2838
|
+
// filed by index — the deck's page order can't depend on which query
|
|
2839
|
+
// happened to finish first.
|
|
2840
|
+
const all$ = slots.length
|
|
2841
|
+
? from(slots.map((slot, index) => ({ slot, index })))
|
|
2842
|
+
.pipe(mergeMap(({ slot, index }) => this._componentData(report, slot.component)
|
|
2843
|
+
.pipe(map$1((item) => ({ item, index }))), EXPORT_CONCURRENCY), toArray())
|
|
2844
|
+
: of([]);
|
|
2752
2845
|
// The summary rides alongside the component data rather than being built at
|
|
2753
2846
|
// the end, because naming a select filter's values is itself a request.
|
|
2754
2847
|
return forkJoin({ collected: all$, filterSummary: this._filterSummary(report) })
|
|
2755
2848
|
.pipe(map$1(({ collected, filterSummary }) => {
|
|
2756
2849
|
const pages = report.pages.map((page) => ({ page, components: [] }));
|
|
2757
|
-
collected
|
|
2850
|
+
[...collected]
|
|
2851
|
+
.sort((a, b) => a.index - b.index)
|
|
2852
|
+
.forEach(({ item, index }) => {
|
|
2758
2853
|
pages[slots[index].pageIndex].components.push(item);
|
|
2759
2854
|
});
|
|
2760
2855
|
return {
|
|
@@ -2773,7 +2868,10 @@ class ReportExportCollectorService {
|
|
|
2773
2868
|
const state = component.type === 'list'
|
|
2774
2869
|
? { page: 1, pageSize: LIST_EXPORT_ROWS }
|
|
2775
2870
|
: (isTimeSeries && frequency ? { frequency } : {});
|
|
2776
|
-
|
|
2871
|
+
// handlers: false keeps the app's global error dialog out of this. A failed
|
|
2872
|
+
// tile is reported once, by the export gate, naming the pages it cost —
|
|
2873
|
+
// never as ten stacked modals carrying the database's own wording.
|
|
2874
|
+
return this._reportData.componentData(report.id, component.id, filters, state, true, { handlers: false })
|
|
2777
2875
|
.pipe(map$1((data) => ({ component, data })), catchError(() => of({ component, data: null })));
|
|
2778
2876
|
}
|
|
2779
2877
|
// "Report Period: Jun 12, 2025 – Jun 12, 2026", "Organization: Alnylam - AHP,
|
|
@@ -2824,12 +2922,10 @@ class ReportExportCollectorService {
|
|
|
2824
2922
|
return resolveOptionValues(loadGroupOptions(group, this._reportData, reportId), values)
|
|
2825
2923
|
.pipe(map$1((resolved) => `${label}: ${resolved.map((item) => item.name).join(', ')}`), catchError(() => of(`${label}: ${values.map((item) => String(item)).join(', ')}`)));
|
|
2826
2924
|
}
|
|
2925
|
+
// Rendered off the same calendar day the filter sends to the server, so the
|
|
2926
|
+
// cover's period is the period the numbers behind it were queried for.
|
|
2827
2927
|
_date(value) {
|
|
2828
|
-
|
|
2829
|
-
return '…';
|
|
2830
|
-
}
|
|
2831
|
-
const date = value instanceof Date ? value : new Date(value);
|
|
2832
|
-
return Number.isNaN(date.getTime()) ? String(value) : format(date, 'MMM d, yyyy');
|
|
2928
|
+
return dateBoundLabel(value) ?? '…';
|
|
2833
2929
|
}
|
|
2834
2930
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportExportCollectorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
2835
2931
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportExportCollectorService });
|
|
@@ -2845,7 +2941,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
2845
2941
|
// complete synchronously after setOption.
|
|
2846
2942
|
async function renderChartImage(component, data, width, height, pixelRatio = 3) {
|
|
2847
2943
|
// The same tree-shaken echarts build + 'report' theme the live canvas uses.
|
|
2848
|
-
const echarts = (await import('./firestitch-report-echarts-
|
|
2944
|
+
const echarts = (await import('./firestitch-report-echarts-DeE74U3a.mjs')).default;
|
|
2849
2945
|
const host = document.createElement('div');
|
|
2850
2946
|
host.style.width = `${width}px`;
|
|
2851
2947
|
host.style.height = `${height}px`;
|
|
@@ -2939,7 +3035,7 @@ class ReportPdfService {
|
|
|
2939
3035
|
doc.setFont('helvetica', 'italic');
|
|
2940
3036
|
doc.setFontSize(9);
|
|
2941
3037
|
doc.setTextColor('#7b8794');
|
|
2942
|
-
doc.text(item
|
|
3038
|
+
doc.text(emptyLabel(item), x + w / 2, bodyY + bodyH / 2, { align: 'center' });
|
|
2943
3039
|
return;
|
|
2944
3040
|
}
|
|
2945
3041
|
switch (component.type) {
|
|
@@ -3099,7 +3195,7 @@ class ReportPptxService {
|
|
|
3099
3195
|
const bodyY = y + TITLE_H;
|
|
3100
3196
|
const bodyH = Math.max(0.3, h - TITLE_H);
|
|
3101
3197
|
if (!item.data || !item.data.rows?.length) {
|
|
3102
|
-
slide.addText(item
|
|
3198
|
+
slide.addText(emptyLabel(item), {
|
|
3103
3199
|
x, y: bodyY, w, h: bodyH,
|
|
3104
3200
|
fontSize: 10, italic: true, color: '7B8794', align: 'center', valign: 'middle',
|
|
3105
3201
|
});
|
|
@@ -3248,6 +3344,75 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
3248
3344
|
type: Injectable
|
|
3249
3345
|
}] });
|
|
3250
3346
|
|
|
3347
|
+
// What the user chose when told the export is incomplete. Retry re-runs the
|
|
3348
|
+
// whole collection — the queries that failed were killed on a clock, not on
|
|
3349
|
+
// anything about the data, so a second attempt against a warmed database
|
|
3350
|
+
// genuinely often succeeds.
|
|
3351
|
+
const RETRY = 'retry';
|
|
3352
|
+
const DOWNLOAD = 'download';
|
|
3353
|
+
// Runs a report export end to end: collect every tile, refuse to write a file
|
|
3354
|
+
// that is missing any of them without saying so, then render.
|
|
3355
|
+
//
|
|
3356
|
+
// The gate is the reason this sits between the collector and the renderers
|
|
3357
|
+
// rather than in either of them. A deck with five blank pages is worse than no
|
|
3358
|
+
// deck: it looks finished, so it gets emailed to a client, and the pages that
|
|
3359
|
+
// are missing are exactly the ones nobody thinks to check. The renderers can
|
|
3360
|
+
// only describe a hole they are handed; the collector only knows one tile at a
|
|
3361
|
+
// time. Whether the FILE should exist is a decision about the whole report, and
|
|
3362
|
+
// this is the only place that sees it.
|
|
3363
|
+
class ReportExportService {
|
|
3364
|
+
_collector = inject(ReportExportCollectorService);
|
|
3365
|
+
_pdfService = inject(ReportPdfService);
|
|
3366
|
+
_pptxService = inject(ReportPptxService);
|
|
3367
|
+
_prompt = inject(FsPrompt);
|
|
3368
|
+
pdf(report) {
|
|
3369
|
+
return this._run(report, (collected) => this._pdfService.export(collected));
|
|
3370
|
+
}
|
|
3371
|
+
powerpoint(report) {
|
|
3372
|
+
return this._run(report, (collected) => this._pptxService.export(collected));
|
|
3373
|
+
}
|
|
3374
|
+
// Retry re-collects the WHOLE report, not just the tiles that failed. Every
|
|
3375
|
+
// number in one file has to have been true at one moment: refetching four
|
|
3376
|
+
// tiles and keeping nine from three minutes ago produces a deck whose pages
|
|
3377
|
+
// disagree with each other, and nothing on it would say so.
|
|
3378
|
+
_run(report, render) {
|
|
3379
|
+
return this._collector.collect(report)
|
|
3380
|
+
.pipe(switchMap((collected) => this._gate(collected)
|
|
3381
|
+
.pipe(switchMap((choice) => choice === RETRY
|
|
3382
|
+
? this._run(report, render)
|
|
3383
|
+
: from(render(collected))))));
|
|
3384
|
+
}
|
|
3385
|
+
// Resolves to DOWNLOAD when the export is whole (or the user accepted an
|
|
3386
|
+
// incomplete one) and to RETRY when they want another attempt. Cancelling
|
|
3387
|
+
// ends the export quietly: EMPTY completes without emitting, so nothing
|
|
3388
|
+
// renders and no error surfaces — they know what they just declined.
|
|
3389
|
+
_gate(collected) {
|
|
3390
|
+
const failed = failedPageNames(collected);
|
|
3391
|
+
if (!failed.length) {
|
|
3392
|
+
return of(DOWNLOAD);
|
|
3393
|
+
}
|
|
3394
|
+
const pages = failed.length === 1
|
|
3395
|
+
? `<strong>${failed[0]}</strong> could not be generated`
|
|
3396
|
+
: `${failed.length} pages could not be generated: <strong>${failed.join('</strong>, <strong>')}</strong>`;
|
|
3397
|
+
return this._prompt.confirm({
|
|
3398
|
+
title: 'Export is incomplete',
|
|
3399
|
+
template: `${pages}. This usually means those tiles took too long to load. `
|
|
3400
|
+
+ 'Retry, or download the file with those pages marked as missing.',
|
|
3401
|
+
buttons: [
|
|
3402
|
+
{ label: 'Retry', value: RETRY, color: 'primary' },
|
|
3403
|
+
{ label: 'Download Anyway', value: DOWNLOAD, color: '' },
|
|
3404
|
+
{ label: 'Cancel', cancel: true, color: '' },
|
|
3405
|
+
],
|
|
3406
|
+
})
|
|
3407
|
+
.pipe(catchError(() => EMPTY));
|
|
3408
|
+
}
|
|
3409
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportExportService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
3410
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportExportService });
|
|
3411
|
+
}
|
|
3412
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportExportService, decorators: [{
|
|
3413
|
+
type: Injectable
|
|
3414
|
+
}] });
|
|
3415
|
+
|
|
3251
3416
|
// Holds the report list in memory so the picker doesn't hit the API on every
|
|
3252
3417
|
// focus. Loaded once when the page opens; create/rename/delete keep the cache
|
|
3253
3418
|
// in sync.
|
|
@@ -3325,9 +3490,7 @@ class ReportComponent {
|
|
|
3325
3490
|
_reportData = inject(ReportData);
|
|
3326
3491
|
_reportService = inject(ReportService);
|
|
3327
3492
|
_filterState = inject(ReportFilterStateService);
|
|
3328
|
-
|
|
3329
|
-
_pptxService = inject(ReportPptxService);
|
|
3330
|
-
_pdfService = inject(ReportPdfService);
|
|
3493
|
+
_exportService = inject(ReportExportService);
|
|
3331
3494
|
_process = inject(FsProcess);
|
|
3332
3495
|
_prompt = inject(FsPrompt);
|
|
3333
3496
|
_dialog = inject(MatDialog);
|
|
@@ -3406,18 +3569,34 @@ class ReportComponent {
|
|
|
3406
3569
|
if (!this.report) {
|
|
3407
3570
|
return;
|
|
3408
3571
|
}
|
|
3409
|
-
this._dialog.open(ComponentSettingsComponent, {
|
|
3572
|
+
const dialogRef = this._dialog.open(ComponentSettingsComponent, {
|
|
3410
3573
|
data: { report: this.report, component },
|
|
3411
3574
|
width: '640px',
|
|
3412
|
-
})
|
|
3413
|
-
|
|
3575
|
+
});
|
|
3576
|
+
// Filter add/remove/level changes persist immediately inside the dialog (no
|
|
3577
|
+
// "save") and can leave it by an exit that carries no result, so they are
|
|
3578
|
+
// announced as they land. The reload waits for the close so the canvas isn't
|
|
3579
|
+
// re-rendering underneath an open dialog.
|
|
3580
|
+
let filtersChanged = false;
|
|
3581
|
+
dialogRef.componentInstance.filtersChanged
|
|
3582
|
+
.pipe(takeUntil(dialogRef.afterClosed()))
|
|
3583
|
+
.subscribe(() => filtersChanged = true);
|
|
3584
|
+
dialogRef.afterClosed()
|
|
3414
3585
|
.pipe(takeUntilDestroyed(this._destroyRef))
|
|
3415
|
-
.subscribe(() => {
|
|
3416
|
-
//
|
|
3417
|
-
//
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3586
|
+
.subscribe((result) => {
|
|
3587
|
+
// The report's filter groups changed, so the report bar and the
|
|
3588
|
+
// on-component strips have to be rebuilt from a fresh report.
|
|
3589
|
+
if (filtersChanged) {
|
|
3590
|
+
if (this.selected) {
|
|
3591
|
+
this._loadReport(this.selected.id);
|
|
3592
|
+
}
|
|
3593
|
+
return;
|
|
3594
|
+
}
|
|
3595
|
+
// Nothing else in the dialog touches the query — title and padding are
|
|
3596
|
+
// presentation. Repaint the one component it was opened for and leave
|
|
3597
|
+
// every other component's data, and its query, alone.
|
|
3598
|
+
if (result?.settings) {
|
|
3599
|
+
this._canvas?.applyComponentSettings(component.id, result.settings);
|
|
3421
3600
|
}
|
|
3422
3601
|
});
|
|
3423
3602
|
}
|
|
@@ -3436,9 +3615,7 @@ class ReportComponent {
|
|
|
3436
3615
|
if (!this.report) {
|
|
3437
3616
|
return;
|
|
3438
3617
|
}
|
|
3439
|
-
|
|
3440
|
-
this._process.run('Exporting PowerPoint', this._exportCollector.collect(report)
|
|
3441
|
-
.pipe(switchMap((collected) => from(this._pptxService.export(collected)))));
|
|
3618
|
+
this._process.run('Exporting PowerPoint', this._exportService.powerpoint(this.report));
|
|
3442
3619
|
}
|
|
3443
3620
|
// Export to PDF, entirely client-side: same collection pass, charts rendered
|
|
3444
3621
|
// through an offscreen ECharts instance at 3× pixel ratio (pixel-identical
|
|
@@ -3447,9 +3624,7 @@ class ReportComponent {
|
|
|
3447
3624
|
if (!this.report) {
|
|
3448
3625
|
return;
|
|
3449
3626
|
}
|
|
3450
|
-
|
|
3451
|
-
this._process.run('Exporting PDF', this._exportCollector.collect(report)
|
|
3452
|
-
.pipe(switchMap((collected) => from(this._pdfService.export(collected)))));
|
|
3627
|
+
this._process.run('Exporting PDF', this._exportService.pdf(this.report));
|
|
3453
3628
|
}
|
|
3454
3629
|
// The raw data behind the whole report in one action: the server exports every
|
|
3455
3630
|
// component's full filtered dataset and zips one CSV per component, foldered
|
|
@@ -3610,9 +3785,10 @@ class ReportComponent {
|
|
|
3610
3785
|
ReportExportCollectorService,
|
|
3611
3786
|
ReportPptxService,
|
|
3612
3787
|
ReportPdfService,
|
|
3788
|
+
ReportExportService,
|
|
3613
3789
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
|
|
3614
3790
|
// this route's chunk.
|
|
3615
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
3791
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-DeE74U3a.mjs').then((module) => module.default) }),
|
|
3616
3792
|
], viewQueries: [{ propertyName: "_split", first: true, predicate: ["split"], descendants: true, static: true }, { propertyName: "_chatPanel", first: true, predicate: ["chatPanel"], descendants: true, read: ElementRef, static: true }, { propertyName: "_canvas", first: true, predicate: ReportCanvasComponent, descendants: true }], ngImport: i0, template: "<div\n #split\n class=\"report fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n style=\"min-height: 500px;\"\n basePath=\"reports\"\n [requestData]=\"{ reportId: selected?.id ?? null }\"\n [introMessage]=\"introMessage\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n <div class=\"viewer fs-flex fs-column\">\n <div class=\"fs-row.align-center.gap-sm\">\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [fetch]=\"fetchReports\"\n [(ngModel)]=\"selected\"\n [disabled]=\"loadingReports\"\n [padless]=\"true\"\n [multiple]=\"false\"\n [fetchOnFocus]=\"true\"\n (ngModelChange)=\"reportChange($event)\"\n placeholder=\"Report\"\n name=\"report\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n <ng-template\n fsAutocompleteChipsStatic\n (click)=\"createReport()\">\n Create Report\n </ng-template>\n </fs-autocomplete-chips>\n @if (report) {\n <fs-menu>\n <ng-template\n fs-menu-item\n (click)=\"reportSettings()\">\n <mat-icon>\n tune\n </mat-icon>\n Report settings\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleEditMode()\"\n [disabled]=\"editMode\">\n <mat-icon>\n dashboard_2_edit\n </mat-icon>\n {{ editMode ? 'Done editing layout' : 'Edit layout' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n <mat-icon>\n slideshow\n </mat-icon>\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n <mat-icon>\n picture_as_pdf\n </mat-icon>\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data\n </ng-template>\n @if ((report?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n }\n </div>\n @if (report) {\n <!-- Report-level filters only (the report's actions live in the menu\n above). fs-filter reads its config once at init, so it's keyed on the\n filter signature: when the report-level filter set changes the block\n is recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters) {\n @for (key of [reportFilterKey]; track key) {\n <fs-filter [config]=\"reportFilterConfig\"></fs-filter>\n }\n }\n <app-report-canvas\n [report]=\"report\"\n [editMode]=\"editMode\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\"\n (editDone)=\"toggleEditMode()\">\n </app-report-canvas>\n }\n </div>\n</div>", styles: [".report{height:100%;min-height:500px}.report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:500px;min-width:0;border:none}.report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.report .viewer fs-filter{flex:0 0 auto;margin-top:10px;margin-bottom:0}.report .viewer ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.report .resizer:before{content:\"\";width:0px;background:#0000001f;transition:width .12s ease,background-color .12s ease}.report .resizer:hover:before{width:3px;background:var(--brand-primary-color)}.report.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.report.resizing .resizer:before{width:3px;background:var(--brand-primary-color)}.report.resizing .chat,.report.resizing .viewer{pointer-events:none}::ng-deep body.body-report-reports .mat-mdc-card-content{display:flex;flex-direction:column;box-sizing:border-box}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel{flex:1;min-height:0}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel router-outlet+ng-component{height:100%}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: FsAutocompleteChipsModule }, { kind: "component", type: i2$2.FsAutocompleteChipsComponent, selector: "fs-autocomplete-chips", inputs: ["fetch", "appearance", "floatLabel", "readonly", "size", "label", "placeholder", "chipImage", "chipBackground", "chipColor", "chipIcon", "chipIconColor", "chipClass", "chipPadding", "shape", "hint", "allowText", "allowObject", "delay", "minPanelWidth", "maxPanelHeight", "validateText", "removable", "allowClear", "color", "background", "orderable", "padless", "initOnClick", "fetchOnFocus", "multiple", "multipleAdd", "confirm", "disabled", "groupBy", "panelWidth", "panelClass", "compareWith"], outputs: ["selected", "removed", "reordered", "clear", "panelOpened", "panelClosed"] }, { kind: "directive", type: i2$2.FsAutocompleteObjectDirective, selector: "[fsAutocompleteObject],[fsAutocompleteChipsTemplate]" }, { kind: "directive", type: i2$2.FsAutocompleteChipsStaticDirective, selector: "[fsAutocompleteChipsStatic]", inputs: ["show", "disable"], outputs: ["click", "selected"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2$3.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$3.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: ReportCanvasComponent, selector: "app-report-canvas", inputs: ["report", "editMode"], outputs: ["componentSettings", "reportChanged", "editDone"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3617
3793
|
}
|
|
3618
3794
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponent, decorators: [{
|
|
@@ -3624,9 +3800,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
3624
3800
|
ReportExportCollectorService,
|
|
3625
3801
|
ReportPptxService,
|
|
3626
3802
|
ReportPdfService,
|
|
3803
|
+
ReportExportService,
|
|
3627
3804
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
|
|
3628
3805
|
// this route's chunk.
|
|
3629
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
3806
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-DeE74U3a.mjs').then((module) => module.default) }),
|
|
3630
3807
|
], imports: [
|
|
3631
3808
|
FormsModule,
|
|
3632
3809
|
FsAutocompleteChipsModule,
|
|
@@ -4701,9 +4878,7 @@ class FsAiReportComponent {
|
|
|
4701
4878
|
_canvas;
|
|
4702
4879
|
_reportData = inject(ReportData);
|
|
4703
4880
|
_filterState = inject(ReportFilterStateService);
|
|
4704
|
-
|
|
4705
|
-
_pptxService = inject(ReportPptxService);
|
|
4706
|
-
_pdfService = inject(ReportPdfService);
|
|
4881
|
+
_exportService = inject(ReportExportService);
|
|
4707
4882
|
_process = inject(FsProcess);
|
|
4708
4883
|
_dialog = inject(MatDialog);
|
|
4709
4884
|
_destroyRef = inject(DestroyRef);
|
|
@@ -4763,17 +4938,33 @@ class FsAiReportComponent {
|
|
|
4763
4938
|
return;
|
|
4764
4939
|
}
|
|
4765
4940
|
const reportId = this.report.id;
|
|
4766
|
-
this._dialog.open(ComponentSettingsComponent, {
|
|
4941
|
+
const dialogRef = this._dialog.open(ComponentSettingsComponent, {
|
|
4767
4942
|
data: { report: this.report, component },
|
|
4768
4943
|
width: '640px',
|
|
4769
|
-
})
|
|
4770
|
-
|
|
4944
|
+
});
|
|
4945
|
+
// Filter add/remove/level changes persist immediately inside the dialog (no
|
|
4946
|
+
// "save") and can leave it by an exit that carries no result, so they are
|
|
4947
|
+
// announced as they land. The reload waits for the close so the canvas isn't
|
|
4948
|
+
// re-rendering underneath an open dialog.
|
|
4949
|
+
let filtersChanged = false;
|
|
4950
|
+
dialogRef.componentInstance.filtersChanged
|
|
4951
|
+
.pipe(takeUntil(dialogRef.afterClosed()))
|
|
4952
|
+
.subscribe(() => filtersChanged = true);
|
|
4953
|
+
dialogRef.afterClosed()
|
|
4771
4954
|
.pipe(takeUntilDestroyed(this._destroyRef))
|
|
4772
|
-
.subscribe(() => {
|
|
4773
|
-
//
|
|
4774
|
-
//
|
|
4775
|
-
|
|
4776
|
-
|
|
4955
|
+
.subscribe((result) => {
|
|
4956
|
+
// The report's filter groups changed, so the report bar and the
|
|
4957
|
+
// on-component strips have to be rebuilt from a fresh report.
|
|
4958
|
+
if (filtersChanged) {
|
|
4959
|
+
this._loadReport(reportId);
|
|
4960
|
+
return;
|
|
4961
|
+
}
|
|
4962
|
+
// Nothing else in the dialog touches the query — title and padding are
|
|
4963
|
+
// presentation. Repaint the one component it was opened for and leave
|
|
4964
|
+
// every other component's data, and its query, alone.
|
|
4965
|
+
if (result?.settings) {
|
|
4966
|
+
this._canvas?.applyComponentSettings(component.id, result.settings);
|
|
4967
|
+
}
|
|
4777
4968
|
});
|
|
4778
4969
|
}
|
|
4779
4970
|
// The canvas changed the report's structure (added a page) — re-fetch so the
|
|
@@ -4789,18 +4980,14 @@ class FsAiReportComponent {
|
|
|
4789
4980
|
if (!this.report) {
|
|
4790
4981
|
return;
|
|
4791
4982
|
}
|
|
4792
|
-
|
|
4793
|
-
this._process.run('Exporting PowerPoint', this._exportCollector.collect(report)
|
|
4794
|
-
.pipe(switchMap((collected) => from(this._pptxService.export(collected)))));
|
|
4983
|
+
this._process.run('Exporting PowerPoint', this._exportService.powerpoint(this.report));
|
|
4795
4984
|
}
|
|
4796
4985
|
// Export to PDF, entirely client-side (offscreen ECharts at 3×; page = PDF page).
|
|
4797
4986
|
exportPdf() {
|
|
4798
4987
|
if (!this.report) {
|
|
4799
4988
|
return;
|
|
4800
4989
|
}
|
|
4801
|
-
|
|
4802
|
-
this._process.run('Exporting PDF', this._exportCollector.collect(report)
|
|
4803
|
-
.pipe(switchMap((collected) => from(this._pdfService.export(collected)))));
|
|
4990
|
+
this._process.run('Exporting PDF', this._exportService.pdf(this.report));
|
|
4804
4991
|
}
|
|
4805
4992
|
// The raw data behind the whole report in one action: the server exports every
|
|
4806
4993
|
// component's full filtered dataset and zips one CSV per component, foldered
|
|
@@ -4948,8 +5135,9 @@ class FsAiReportComponent {
|
|
|
4948
5135
|
ReportExportCollectorService,
|
|
4949
5136
|
ReportPptxService,
|
|
4950
5137
|
ReportPdfService,
|
|
5138
|
+
ReportExportService,
|
|
4951
5139
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
|
|
4952
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
5140
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-DeE74U3a.mjs').then((module) => module.default) }),
|
|
4953
5141
|
], viewQueries: [{ propertyName: "_split", first: true, predicate: ["split"], descendants: true, static: true }, { propertyName: "_chatPanel", first: true, predicate: ["chatPanel"], descendants: true, read: ElementRef, static: true }, { propertyName: "_canvas", first: true, predicate: ReportCanvasComponent, descendants: true }], ngImport: i0, template: "<div class=\"fs-ai-report fs-column\">\n\n <div class=\"fs-ai-report__header fs-row.align-center\">\n <h2 class=\"fs-ai-report__title fs-flex\">{{ report?.name }}</h2>\n\n @if (report) {\n <!-- Report-level filters, inline with the toolbar actions. fs-filter\n reads its config once at init, so it's keyed on the filter\n signature: when the report-level filter set changes the block is\n recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters) {\n @for (key of [reportFilterKey]; track key) {\n <fs-filter\n class=\"fs-ai-report__filters\"\n [config]=\"reportFilterConfig\">\n </fs-filter>\n }\n }\n\n <button\n mat-icon-button\n type=\"button\"\n [matTooltip]=\"editMode ? 'Done' : 'Edit layout'\"\n (click)=\"toggleEditMode()\">\n <mat-icon>{{ editMode ? 'lock' : 'open_with' }}</mat-icon>\n </button>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Export\"\n [fsMenuTriggerFor]=\"exportMenu\">\n <mat-icon>download</mat-icon>\n </button>\n <fs-menu #exportMenu>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n Export Raw Data\n </ng-template>\n @if ((report?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Report settings\"\n (click)=\"reportSettings()\">\n <mat-icon>settings</mat-icon>\n </button>\n }\n </div>\n\n <div\n #split\n class=\"fs-ai-report__split fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n [basePath]=\"basePath()\"\n [requestData]=\"{ reportId: reportId() }\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n\n <div class=\"viewer fs-flex fs-column\">\n @if (report) {\n <app-report-canvas\n [report]=\"report\"\n [editMode]=\"editMode\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\"\n (editDone)=\"toggleEditMode()\">\n </app-report-canvas>\n }\n </div>\n </div>\n</div>\n", styles: [".fs-ai-report{height:100%;min-height:500px;display:flex;flex-direction:column}.fs-ai-report__header{flex:0 0 auto;margin-bottom:10px}.fs-ai-report__title{margin:0;min-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fs-ai-report__filters{flex:0 1 auto;min-width:0;display:block;margin:0 5px 0 0}.fs-ai-report__filters ::ng-deep .filter-bar-container{align-items:center!important}.fs-ai-report__filters ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.fs-ai-report__split{flex:1 1 auto;min-height:0}.fs-ai-report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:400px;min-width:0;border:none}.fs-ai-report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.fs-ai-report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.fs-ai-report .resizer:before{content:\"\";width:3px;border-radius:3px;background:transparent;transition:background-color .12s ease}.fs-ai-report .resizer:hover:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.fs-ai-report__split.resizing .resizer:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing .chat,.fs-ai-report__split.resizing .viewer{pointer-events:none}\n"], dependencies: [{ kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2$3.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$3.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2$3.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3$2.MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i4$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2$5.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: ReportCanvasComponent, selector: "app-report-canvas", inputs: ["report", "editMode"], outputs: ["componentSettings", "reportChanged", "editDone"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
4954
5142
|
}
|
|
4955
5143
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FsAiReportComponent, decorators: [{
|
|
@@ -4961,8 +5149,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
4961
5149
|
ReportExportCollectorService,
|
|
4962
5150
|
ReportPptxService,
|
|
4963
5151
|
ReportPdfService,
|
|
5152
|
+
ReportExportService,
|
|
4964
5153
|
// Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
|
|
4965
|
-
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-
|
|
5154
|
+
provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-DeE74U3a.mjs').then((module) => module.default) }),
|
|
4966
5155
|
], imports: [
|
|
4967
5156
|
FsFilterModule,
|
|
4968
5157
|
FsMenuModule,
|
|
@@ -4996,4 +5185,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
4996
5185
|
*/
|
|
4997
5186
|
|
|
4998
5187
|
export { FsAiReportsComponent as F, GRANULARITY_OPTIONS as G, REPORT_CHART_COLORS_CSS as R, ReportComponent as a, FsAiReportComponent as b, ReportData as c, ReportService as d, ReportFilterStateService as e, ReportNodeType as f, ReportState as g, ReportStates as h };
|
|
4999
|
-
//# sourceMappingURL=firestitch-report-firestitch-report-
|
|
5188
|
+
//# sourceMappingURL=firestitch-report-firestitch-report-Nxl-C1w0.mjs.map
|