@firestitch/report 18.0.28 → 18.0.30

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.
Files changed (27) hide show
  1. package/app/reports/components/report-canvas/report-canvas.component.d.ts +3 -1
  2. package/app/reports/components/report-component/report-component.component.d.ts +2 -1
  3. package/app/reports/data/report.data.d.ts +1 -1
  4. package/app/reports/dialogs/component-settings/component-settings.component.d.ts +6 -4
  5. package/app/reports/export/report-export-collector.service.d.ts +2 -0
  6. package/app/reports/export/report-export.service.d.ts +15 -0
  7. package/app/reports/interfaces/report.interface.d.ts +4 -0
  8. package/app/reports/views/report/fs-ai-report.component.d.ts +1 -3
  9. package/app/reports/views/report/report.component.d.ts +1 -3
  10. package/esm2022/app/reports/components/report-canvas/report-canvas.component.mjs +17 -3
  11. package/esm2022/app/reports/components/report-component/report-component.component.mjs +23 -7
  12. package/esm2022/app/reports/data/report.data.mjs +8 -3
  13. package/esm2022/app/reports/dialogs/component-settings/component-settings.component.mjs +10 -11
  14. package/esm2022/app/reports/export/report-export-collector.service.mjs +59 -11
  15. package/esm2022/app/reports/export/report-export.service.mjs +77 -0
  16. package/esm2022/app/reports/export/report-pdf.service.mjs +3 -2
  17. package/esm2022/app/reports/export/report-pptx.service.mjs +3 -2
  18. package/esm2022/app/reports/interfaces/report.interface.mjs +1 -1
  19. package/esm2022/app/reports/views/report/fs-ai-report.component.mjs +32 -20
  20. package/esm2022/app/reports/views/report/report.component.mjs +33 -21
  21. package/fesm2022/{firestitch-report-echarts-CTReuXLz.mjs → firestitch-report-echarts-CvuyZRgq.mjs} +2 -2
  22. package/fesm2022/{firestitch-report-echarts-CTReuXLz.mjs.map → firestitch-report-echarts-CvuyZRgq.mjs.map} +1 -1
  23. package/fesm2022/{firestitch-report-firestitch-report-DdFptz2k.mjs → firestitch-report-firestitch-report-C00Q9Orr.mjs} +247 -72
  24. package/fesm2022/firestitch-report-firestitch-report-C00Q9Orr.mjs.map +1 -0
  25. package/fesm2022/firestitch-report.mjs +1 -1
  26. package/package.json +1 -1
  27. package/fesm2022/firestitch-report-firestitch-report-DdFptz2k.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 { of, Subject, map as map$1, debounceTime, forkJoin, BehaviorSubject, from, switchMap as switchMap$1 } from 'rxjs';
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,6 +26,7 @@ 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';
29
+ import { of, Subject, map as map$1, debounceTime, forkJoin, from, EMPTY, BehaviorSubject, switchMap as switchMap$1 } from 'rxjs';
30
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';
@@ -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
- componentData(reportId, componentId, filters = [], state = {}, config = {}) {
194
- return this._api.post(this._path(`${reportId}/components/${componentId}/data`), { filters, state }, {
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
  });
@@ -1583,11 +1588,16 @@ class ReportComponentComponent {
1583
1588
  this._buildFilterConfig();
1584
1589
  }
1585
1590
  }
1586
- // fs-filter reads its config only once at init, so the header is keyed on the
1587
- // set of component-level filter groups: the block is recreated (re-reading
1588
- // the rebuilt config) whenever that set changes.
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.
1589
1596
  get filterKey() {
1590
- return this.componentFilterGroups.map((group) => `${group.id}:${group.level}`).join('|');
1597
+ return [
1598
+ this.component.title,
1599
+ ...this.componentFilterGroups.map((group) => `${group.id}:${group.level}`),
1600
+ ].join('|');
1591
1601
  }
1592
1602
  get truncated() {
1593
1603
  return !!this.data?.truncated;
@@ -1623,6 +1633,17 @@ class ReportComponentComponent {
1623
1633
  settings() {
1624
1634
  this.openSettings.emit(this.component);
1625
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
+ }
1626
1647
  exportCsv() {
1627
1648
  const filters = this._filterState.resolveForComponent(this.component);
1628
1649
  this._process.download(`Exporting ${this.component.title ?? 'component'}`, this._reportData.exportComponent(this.reportId, this.component.id, filters));
@@ -1881,7 +1902,7 @@ class ReportComponentComponent {
1881
1902
  });
1882
1903
  }
1883
1904
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1884
- 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 });
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;--mat-button-outlined-container-height: 30px;--mat-button-outlined-label-text-size: 10px;--mat-button-outlined-horizontal-padding: 15px;--mat-button-text-container-height: 30px;--mat-button-text-label-text-size: 10px;--mat-button-text-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 });
1885
1906
  }
1886
1907
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, decorators: [{
1887
1908
  type: Component,
@@ -1894,7 +1915,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
1894
1915
  ComponentChartComponent,
1895
1916
  ComponentKpiComponent,
1896
1917
  ComponentListComponent,
1897
- ], 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"] }]
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;--mat-button-outlined-container-height: 30px;--mat-button-outlined-label-text-size: 10px;--mat-button-outlined-horizontal-padding: 15px;--mat-button-text-container-height: 30px;--mat-button-text-label-text-size: 10px;--mat-button-text-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"] }]
1898
1919
  }], propDecorators: { reportId: [{
1899
1920
  type: Input
1900
1921
  }], component: [{
@@ -1952,6 +1973,9 @@ class ReportCanvasComponent {
1952
1973
  // editMode flag, so it flips it off (mirrors the menu's "Done editing layout").
1953
1974
  editDone = new EventEmitter();
1954
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;
1955
1979
  _viewport;
1956
1980
  _guides;
1957
1981
  PX = PX_PER_INCH;
@@ -2022,6 +2046,14 @@ class ReportCanvasComponent {
2022
2046
  pageLabel(page, index) {
2023
2047
  return page.name || `Page ${index + 1}`;
2024
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
+ }
2025
2057
  selectComponent(componentId) {
2026
2058
  this.selectedComponentId = componentId;
2027
2059
  this._cdRef.markForCheck();
@@ -2231,7 +2263,7 @@ class ReportCanvasComponent {
2231
2263
  this._guides?.nativeElement?.replaceChildren();
2232
2264
  }
2233
2265
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportCanvasComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2234
- 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 });
2235
2267
  }
2236
2268
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportCanvasComponent, decorators: [{
2237
2269
  type: Component,
@@ -2255,6 +2287,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
2255
2287
  }], zoomPan: [{
2256
2288
  type: ViewChild,
2257
2289
  args: [FsZoomPanComponent]
2290
+ }], _renderedComponents: [{
2291
+ type: ViewChildren,
2292
+ args: [ReportComponentComponent]
2258
2293
  }], _viewport: [{
2259
2294
  type: ViewChild,
2260
2295
  args: ['viewport', { static: true }]
@@ -2273,6 +2308,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
2273
2308
  // immediately and refreshes the in-dialog list.
2274
2309
  // SQL / Config — read-only diagnostics.
2275
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();
2276
2316
  component;
2277
2317
  report;
2278
2318
  selectedTab = 'settings';
@@ -2297,9 +2337,6 @@ class ComponentSettingsComponent {
2297
2337
  dateRange: 'Date range',
2298
2338
  keyword: 'Search text',
2299
2339
  };
2300
- // True once any filter mutation happened — the parent reloads the report so
2301
- // the canvas + report bar reflect the change.
2302
- _changed = false;
2303
2340
  _data = inject(MAT_DIALOG_DATA);
2304
2341
  _dialogRef = inject(MatDialogRef);
2305
2342
  _reportData = inject(ReportData);
@@ -2318,9 +2355,6 @@ class ComponentSettingsComponent {
2318
2355
  this._seedFilters(this.report, this.component.id);
2319
2356
  this._loadColumns();
2320
2357
  }
2321
- get hasChanges() {
2322
- return this._changed;
2323
- }
2324
2358
  // Human-friendly name for the inferred interface shown beside each column.
2325
2359
  typeLabel(type) {
2326
2360
  return this._typeLabels[type] ?? type;
@@ -2335,10 +2369,10 @@ class ComponentSettingsComponent {
2335
2369
  left: this.paddingLeft,
2336
2370
  },
2337
2371
  };
2338
- return this._reportData.updateComponent(this.report.id, this.component.id, settings)
2372
+ return this._reportData.updateComponent(this.report.id, this.component.id, { ...settings })
2339
2373
  .pipe(tap(() => {
2340
2374
  this._message.success('Component saved');
2341
- this._dialogRef.close(true);
2375
+ this._dialogRef.close({ settings });
2342
2376
  }));
2343
2377
  };
2344
2378
  // Flip one exposure (report bar or this component) for a row. We update the
@@ -2396,7 +2430,7 @@ class ComponentSettingsComponent {
2396
2430
  // After any filter mutation: re-fetch the report so the dialog's filter list
2397
2431
  // (and the parent canvas, on close) reflect the new groups/levels.
2398
2432
  _afterMutation() {
2399
- this._changed = true;
2433
+ this.filtersChanged.emit();
2400
2434
  this._reportData.get(this.report.id)
2401
2435
  .subscribe((report) => {
2402
2436
  this.report = report;
@@ -2748,29 +2782,74 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
2748
2782
  // Rows fetched for a list component's export table — enough to fill a
2749
2783
  // slide/page table; the cap note covers the rest.
2750
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
+ }
2751
2819
  // Gathers everything an export needs: every component's data across ALL pages
2752
2820
  // (the canvas only renders the active page, so exports can't scrape the
2753
- // screen), fetched fresh with the viewer's current filter state. One failed
2754
- // component degrades to a note in the deck, never a failed export.
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.
2755
2826
  class ReportExportCollectorService {
2756
2827
  _reportData = inject(ReportData);
2757
2828
  _filterState = inject(ReportFilterStateService);
2758
2829
  collect(report) {
2759
- const requests = [];
2760
2830
  const slots = [];
2761
2831
  report.pages.forEach((page, pageIndex) => {
2762
2832
  for (const component of page.components) {
2763
- slots.push({ pageIndex });
2764
- requests.push(this._componentData(report, component));
2833
+ slots.push({ pageIndex, component });
2765
2834
  }
2766
2835
  });
2767
- const all$ = requests.length ? forkJoin(requests) : of([]);
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([]);
2768
2845
  // The summary rides alongside the component data rather than being built at
2769
2846
  // the end, because naming a select filter's values is itself a request.
2770
2847
  return forkJoin({ collected: all$, filterSummary: this._filterSummary(report) })
2771
2848
  .pipe(map$1(({ collected, filterSummary }) => {
2772
2849
  const pages = report.pages.map((page) => ({ page, components: [] }));
2773
- collected.forEach((item, index) => {
2850
+ [...collected]
2851
+ .sort((a, b) => a.index - b.index)
2852
+ .forEach(({ item, index }) => {
2774
2853
  pages[slots[index].pageIndex].components.push(item);
2775
2854
  });
2776
2855
  return {
@@ -2789,7 +2868,10 @@ class ReportExportCollectorService {
2789
2868
  const state = component.type === 'list'
2790
2869
  ? { page: 1, pageSize: LIST_EXPORT_ROWS }
2791
2870
  : (isTimeSeries && frequency ? { frequency } : {});
2792
- return this._reportData.componentData(report.id, component.id, filters, state)
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 })
2793
2875
  .pipe(map$1((data) => ({ component, data })), catchError(() => of({ component, data: null })));
2794
2876
  }
2795
2877
  // "Report Period: Jun 12, 2025 – Jun 12, 2026", "Organization: Alnylam - AHP,
@@ -2859,7 +2941,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
2859
2941
  // complete synchronously after setOption.
2860
2942
  async function renderChartImage(component, data, width, height, pixelRatio = 3) {
2861
2943
  // The same tree-shaken echarts build + 'report' theme the live canvas uses.
2862
- const echarts = (await import('./firestitch-report-echarts-CTReuXLz.mjs')).default;
2944
+ const echarts = (await import('./firestitch-report-echarts-CvuyZRgq.mjs')).default;
2863
2945
  const host = document.createElement('div');
2864
2946
  host.style.width = `${width}px`;
2865
2947
  host.style.height = `${height}px`;
@@ -2953,7 +3035,7 @@ class ReportPdfService {
2953
3035
  doc.setFont('helvetica', 'italic');
2954
3036
  doc.setFontSize(9);
2955
3037
  doc.setTextColor('#7b8794');
2956
- doc.text(item.data ? 'No data' : 'Data unavailable', x + w / 2, bodyY + bodyH / 2, { align: 'center' });
3038
+ doc.text(emptyLabel(item), x + w / 2, bodyY + bodyH / 2, { align: 'center' });
2957
3039
  return;
2958
3040
  }
2959
3041
  switch (component.type) {
@@ -3113,7 +3195,7 @@ class ReportPptxService {
3113
3195
  const bodyY = y + TITLE_H;
3114
3196
  const bodyH = Math.max(0.3, h - TITLE_H);
3115
3197
  if (!item.data || !item.data.rows?.length) {
3116
- slide.addText(item.data ? 'No data' : 'Data unavailable', {
3198
+ slide.addText(emptyLabel(item), {
3117
3199
  x, y: bodyY, w, h: bodyH,
3118
3200
  fontSize: 10, italic: true, color: '7B8794', align: 'center', valign: 'middle',
3119
3201
  });
@@ -3262,6 +3344,75 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
3262
3344
  type: Injectable
3263
3345
  }] });
3264
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
+
3265
3416
  // Holds the report list in memory so the picker doesn't hit the API on every
3266
3417
  // focus. Loaded once when the page opens; create/rename/delete keep the cache
3267
3418
  // in sync.
@@ -3339,9 +3490,7 @@ class ReportComponent {
3339
3490
  _reportData = inject(ReportData);
3340
3491
  _reportService = inject(ReportService);
3341
3492
  _filterState = inject(ReportFilterStateService);
3342
- _exportCollector = inject(ReportExportCollectorService);
3343
- _pptxService = inject(ReportPptxService);
3344
- _pdfService = inject(ReportPdfService);
3493
+ _exportService = inject(ReportExportService);
3345
3494
  _process = inject(FsProcess);
3346
3495
  _prompt = inject(FsPrompt);
3347
3496
  _dialog = inject(MatDialog);
@@ -3420,18 +3569,34 @@ class ReportComponent {
3420
3569
  if (!this.report) {
3421
3570
  return;
3422
3571
  }
3423
- this._dialog.open(ComponentSettingsComponent, {
3572
+ const dialogRef = this._dialog.open(ComponentSettingsComponent, {
3424
3573
  data: { report: this.report, component },
3425
3574
  width: '640px',
3426
- })
3427
- .afterClosed()
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()
3428
3585
  .pipe(takeUntilDestroyed(this._destroyRef))
3429
- .subscribe(() => {
3430
- // Always reload: filter add/remove/level changes persist immediately
3431
- // inside the dialog (no "save"), so the canvas + report bar must
3432
- // refresh however the dialog was closed.
3433
- if (this.selected) {
3434
- this._loadReport(this.selected.id);
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);
3435
3600
  }
3436
3601
  });
3437
3602
  }
@@ -3450,9 +3615,7 @@ class ReportComponent {
3450
3615
  if (!this.report) {
3451
3616
  return;
3452
3617
  }
3453
- const report = this.report;
3454
- this._process.run('Exporting PowerPoint', this._exportCollector.collect(report)
3455
- .pipe(switchMap((collected) => from(this._pptxService.export(collected)))));
3618
+ this._process.run('Exporting PowerPoint', this._exportService.powerpoint(this.report));
3456
3619
  }
3457
3620
  // Export to PDF, entirely client-side: same collection pass, charts rendered
3458
3621
  // through an offscreen ECharts instance at 3× pixel ratio (pixel-identical
@@ -3461,9 +3624,7 @@ class ReportComponent {
3461
3624
  if (!this.report) {
3462
3625
  return;
3463
3626
  }
3464
- const report = this.report;
3465
- this._process.run('Exporting PDF', this._exportCollector.collect(report)
3466
- .pipe(switchMap((collected) => from(this._pdfService.export(collected)))));
3627
+ this._process.run('Exporting PDF', this._exportService.pdf(this.report));
3467
3628
  }
3468
3629
  // The raw data behind the whole report in one action: the server exports every
3469
3630
  // component's full filtered dataset and zips one CSV per component, foldered
@@ -3624,9 +3785,10 @@ class ReportComponent {
3624
3785
  ReportExportCollectorService,
3625
3786
  ReportPptxService,
3626
3787
  ReportPdfService,
3788
+ ReportExportService,
3627
3789
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
3628
3790
  // this route's chunk.
3629
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CTReuXLz.mjs').then((module) => module.default) }),
3791
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CvuyZRgq.mjs').then((module) => module.default) }),
3630
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 });
3631
3793
  }
3632
3794
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponent, decorators: [{
@@ -3638,9 +3800,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
3638
3800
  ReportExportCollectorService,
3639
3801
  ReportPptxService,
3640
3802
  ReportPdfService,
3803
+ ReportExportService,
3641
3804
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
3642
3805
  // this route's chunk.
3643
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CTReuXLz.mjs').then((module) => module.default) }),
3806
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CvuyZRgq.mjs').then((module) => module.default) }),
3644
3807
  ], imports: [
3645
3808
  FormsModule,
3646
3809
  FsAutocompleteChipsModule,
@@ -4715,9 +4878,7 @@ class FsAiReportComponent {
4715
4878
  _canvas;
4716
4879
  _reportData = inject(ReportData);
4717
4880
  _filterState = inject(ReportFilterStateService);
4718
- _exportCollector = inject(ReportExportCollectorService);
4719
- _pptxService = inject(ReportPptxService);
4720
- _pdfService = inject(ReportPdfService);
4881
+ _exportService = inject(ReportExportService);
4721
4882
  _process = inject(FsProcess);
4722
4883
  _dialog = inject(MatDialog);
4723
4884
  _destroyRef = inject(DestroyRef);
@@ -4777,17 +4938,33 @@ class FsAiReportComponent {
4777
4938
  return;
4778
4939
  }
4779
4940
  const reportId = this.report.id;
4780
- this._dialog.open(ComponentSettingsComponent, {
4941
+ const dialogRef = this._dialog.open(ComponentSettingsComponent, {
4781
4942
  data: { report: this.report, component },
4782
4943
  width: '640px',
4783
- })
4784
- .afterClosed()
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()
4785
4954
  .pipe(takeUntilDestroyed(this._destroyRef))
4786
- .subscribe(() => {
4787
- // Always reload: filter add/remove/level changes persist immediately
4788
- // inside the dialog (no "save"), so the canvas + report bar must
4789
- // refresh however the dialog was closed.
4790
- this._loadReport(reportId);
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
+ }
4791
4968
  });
4792
4969
  }
4793
4970
  // The canvas changed the report's structure (added a page) — re-fetch so the
@@ -4803,18 +4980,14 @@ class FsAiReportComponent {
4803
4980
  if (!this.report) {
4804
4981
  return;
4805
4982
  }
4806
- const report = this.report;
4807
- this._process.run('Exporting PowerPoint', this._exportCollector.collect(report)
4808
- .pipe(switchMap((collected) => from(this._pptxService.export(collected)))));
4983
+ this._process.run('Exporting PowerPoint', this._exportService.powerpoint(this.report));
4809
4984
  }
4810
4985
  // Export to PDF, entirely client-side (offscreen ECharts at 3×; page = PDF page).
4811
4986
  exportPdf() {
4812
4987
  if (!this.report) {
4813
4988
  return;
4814
4989
  }
4815
- const report = this.report;
4816
- this._process.run('Exporting PDF', this._exportCollector.collect(report)
4817
- .pipe(switchMap((collected) => from(this._pdfService.export(collected)))));
4990
+ this._process.run('Exporting PDF', this._exportService.pdf(this.report));
4818
4991
  }
4819
4992
  // The raw data behind the whole report in one action: the server exports every
4820
4993
  // component's full filtered dataset and zips one CSV per component, foldered
@@ -4962,8 +5135,9 @@ class FsAiReportComponent {
4962
5135
  ReportExportCollectorService,
4963
5136
  ReportPptxService,
4964
5137
  ReportPdfService,
5138
+ ReportExportService,
4965
5139
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
4966
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CTReuXLz.mjs').then((module) => module.default) }),
5140
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CvuyZRgq.mjs').then((module) => module.default) }),
4967
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 });
4968
5142
  }
4969
5143
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FsAiReportComponent, decorators: [{
@@ -4975,8 +5149,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
4975
5149
  ReportExportCollectorService,
4976
5150
  ReportPptxService,
4977
5151
  ReportPdfService,
5152
+ ReportExportService,
4978
5153
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
4979
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CTReuXLz.mjs').then((module) => module.default) }),
5154
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CvuyZRgq.mjs').then((module) => module.default) }),
4980
5155
  ], imports: [
4981
5156
  FsFilterModule,
4982
5157
  FsMenuModule,
@@ -5010,4 +5185,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
5010
5185
  */
5011
5186
 
5012
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 };
5013
- //# sourceMappingURL=firestitch-report-firestitch-report-DdFptz2k.mjs.map
5188
+ //# sourceMappingURL=firestitch-report-firestitch-report-C00Q9Orr.mjs.map