@firestitch/report 18.0.27 → 18.0.29

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