@firestitch/report 18.5.0 → 18.6.0

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 (59) hide show
  1. package/app/reports/components/component-list/component-list.component.d.ts +3 -6
  2. package/app/reports/components/report-canvas/report-canvas.component.d.ts +4 -4
  3. package/app/reports/components/report-component/report-component.component.d.ts +5 -5
  4. package/app/reports/components/timezone-select/timezone-select.component.d.ts +4 -6
  5. package/app/reports/consts/report-week.const.d.ts +2 -0
  6. package/app/reports/data/report-ai-chat.data.d.ts +12 -0
  7. package/app/reports/data/report.data.d.ts +3 -3
  8. package/app/reports/dialogs/component-settings/component-settings.component.d.ts +6 -6
  9. package/app/reports/dialogs/report-settings/report-settings.component.d.ts +5 -0
  10. package/app/reports/export/report-export-collector.service.d.ts +14 -2
  11. package/app/reports/export/report-pdf.service.d.ts +14 -1
  12. package/app/reports/export/report-pptx.service.d.ts +2 -1
  13. package/app/reports/format.d.ts +7 -0
  14. package/app/reports/interfaces/report-chat-viewer-filter.interface.d.ts +7 -0
  15. package/app/reports/interfaces/report-chat-viewer-state.interface.d.ts +7 -0
  16. package/app/reports/interfaces/report.interface.d.ts +17 -2
  17. package/app/reports/pipes/report-date.pipe.d.ts +7 -0
  18. package/app/reports/report-filter-items.d.ts +5 -0
  19. package/app/reports/report-url-state.d.ts +14 -0
  20. package/app/reports/services/report-filter-state.service.d.ts +24 -2
  21. package/app/reports/theme/typography.d.ts +2 -0
  22. package/app/reports/views/report/fs-ai-report.component.d.ts +16 -8
  23. package/app/reports/views/report/report.component.d.ts +6 -6
  24. package/esm2022/app/reports/components/component-list/component-list.component.mjs +29 -104
  25. package/esm2022/app/reports/components/report-canvas/report-canvas.component.mjs +52 -44
  26. package/esm2022/app/reports/components/report-component/report-component.component.mjs +55 -35
  27. package/esm2022/app/reports/components/timezone-select/timezone-select.component.mjs +23 -23
  28. package/esm2022/app/reports/consts/report-week.const.mjs +7 -0
  29. package/esm2022/app/reports/data/report-ai-chat.data.mjs +25 -0
  30. package/esm2022/app/reports/data/report.data.mjs +22 -9
  31. package/esm2022/app/reports/dialogs/component-settings/component-settings.component.mjs +25 -26
  32. package/esm2022/app/reports/dialogs/report-settings/report-settings.component.mjs +38 -13
  33. package/esm2022/app/reports/export/report-export-collector.service.mjs +92 -28
  34. package/esm2022/app/reports/export/report-export.service.mjs +11 -8
  35. package/esm2022/app/reports/export/report-pdf.service.mjs +241 -81
  36. package/esm2022/app/reports/export/report-pptx.service.mjs +60 -45
  37. package/esm2022/app/reports/format.mjs +119 -8
  38. package/esm2022/app/reports/interfaces/report-chat-viewer-filter.interface.mjs +2 -0
  39. package/esm2022/app/reports/interfaces/report-chat-viewer-state.interface.mjs +2 -0
  40. package/esm2022/app/reports/interfaces/report.interface.mjs +1 -1
  41. package/esm2022/app/reports/option-builder.mjs +131 -15
  42. package/esm2022/app/reports/pipes/report-date.pipe.mjs +21 -0
  43. package/esm2022/app/reports/report-filter-items.mjs +42 -10
  44. package/esm2022/app/reports/report-url-state.mjs +144 -0
  45. package/esm2022/app/reports/services/report-filter-state.service.mjs +308 -64
  46. package/esm2022/app/reports/theme/echarts.mjs +6 -5
  47. package/esm2022/app/reports/theme/typography.mjs +6 -0
  48. package/esm2022/app/reports/views/report/fs-ai-report.component.mjs +191 -90
  49. package/esm2022/app/reports/views/report/report.component.mjs +64 -62
  50. package/fesm2022/{firestitch-report-echarts-CtKYuRcT.mjs → firestitch-report-echarts-B9nl9XKM.mjs} +6 -6
  51. package/fesm2022/firestitch-report-echarts-B9nl9XKM.mjs.map +1 -0
  52. package/fesm2022/{firestitch-report-firestitch-report-Df1IXrEs.mjs → firestitch-report-firestitch-report-Bb65HEYB.mjs} +1707 -703
  53. package/fesm2022/firestitch-report-firestitch-report-Bb65HEYB.mjs.map +1 -0
  54. package/fesm2022/firestitch-report.mjs +1 -1
  55. package/package.json +1 -1
  56. package/app/reports/dialogs/report-settings/report-settings-default.d.ts +0 -2
  57. package/esm2022/app/reports/dialogs/report-settings/report-settings-default.mjs +0 -22
  58. package/fesm2022/firestitch-report-echarts-CtKYuRcT.mjs.map +0 -1
  59. package/fesm2022/firestitch-report-firestitch-report-Df1IXrEs.mjs.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, Injectable, Component, ChangeDetectionStrategy, Input, signal, DestroyRef, ViewChild, input, output, computed, viewChild, ElementRef, effect, untracked, EventEmitter, ChangeDetectorRef, NgZone, Output, HostBinding, ViewChildren, CUSTOM_ELEMENTS_SCHEMA, model } from '@angular/core';
2
+ import { inject, Injectable, signal, Component, ChangeDetectionStrategy, Input, Pipe, DestroyRef, ViewChild, input, output, computed, viewChild, ElementRef, effect, untracked, EventEmitter, NgZone, Output, HostBinding, ViewChildren, CUSTOM_ELEMENTS_SCHEMA, model } from '@angular/core';
3
3
  import * as i1$1 from '@angular/forms';
4
4
  import { FormsModule, ControlContainer, NgForm } from '@angular/forms';
5
5
  import * as i3$3 from '@angular/material/button';
@@ -17,22 +17,22 @@ import * as i2$1 from '@firestitch/menu';
17
17
  import { FsMenuModule } from '@firestitch/menu';
18
18
  import { FsProcess } from '@firestitch/process';
19
19
  import { FsPrompt } from '@firestitch/prompt';
20
- import { map, shareReplay, filter, switchMap as switchMap$1, tap, mergeMap, toArray, catchError, finalize, takeUntil, take } from 'rxjs/operators';
20
+ import { map, shareReplay, filter, switchMap as switchMap$1, tap as tap$1, mergeMap, toArray, catchError, finalize, takeUntil, take, auditTime } from 'rxjs/operators';
21
21
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
22
22
  import { NgxEchartsDirective, provideEchartsCore } from 'ngx-echarts';
23
- import { FsAiChatComponent } from '@firestitch/ai';
23
+ import { FsAiChatComponent, FsAiChatData } from '@firestitch/ai';
24
24
  import * as i1$3 from '@angular/cdk/drag-drop';
25
25
  import { moveItemInArray, DragDropModule } from '@angular/cdk/drag-drop';
26
26
  import * as i5$1 from '@firestitch/message';
27
27
  import { FsMessage, FsMessageModule } from '@firestitch/message';
28
28
  import * as i3 from '@firestitch/zoom-pan';
29
29
  import { FsZoomPanComponent, FsZoomPanModule } from '@firestitch/zoom-pan';
30
- import { of, Subject, map as map$1, debounceTime, switchMap, forkJoin, from, EMPTY, BehaviorSubject } from 'rxjs';
30
+ import { of, Subject, map as map$1, debounceTime, switchMap, tap, forkJoin, from, EMPTY, BehaviorSubject, merge } from 'rxjs';
31
31
  import { FsApi, StreamEventType } from '@firestitch/api';
32
32
  import * as i1$2 from '@angular/material/menu';
33
33
  import { MatMenuModule } from '@angular/material/menu';
34
- import { format, parseISO, startOfYear, startOfQuarter, startOfMonth, subMonths, subDays, subWeeks, startOfWeek, endOfWeek, isValid } from 'date-fns';
35
- import { DatePipe } from '@angular/common';
34
+ import { format, parseISO, isValid, startOfYear, startOfQuarter, startOfMonth, subMonths, subDays, subWeeks, startOfWeek, endOfWeek } from 'date-fns';
35
+ import { formatInTimeZone, toZonedTime } from 'date-fns-tz';
36
36
  import * as i1 from '@firestitch/list';
37
37
  import { PaginationStrategy, FsListComponent, FsListModule } from '@firestitch/list';
38
38
  import { DomSanitizer } from '@angular/platform-browser';
@@ -262,22 +262,35 @@ class ReportData {
262
262
  ...config,
263
263
  });
264
264
  }
265
- // Exports the component's FULL filtered dataset to CSV server-side and
266
- // resolves to a presigned download URL. The backend streams percentage
267
- // progress while chunking; consume through FsProcess.download.
265
+ // Exports one component to CSV server-side: the file is what the screen
266
+ // shows (a chart or KPI as rendered, a list in full in the screen's order).
267
+ // The backend streams percentage progress while it writes.
268
+ //
269
+ // key: null on purpose, unlike the key: 'url' presigned-export pattern: the
270
+ // response also carries `notes`, the sentences saying how the file falls
271
+ // short of the full result, and the caller has to show them before it hands
272
+ // the url to FsProcess.download.
268
273
  exportComponent(reportId, componentId, filters = [], state = {}, config = {}) {
269
274
  return this._api.post(this._path(`${reportId}/components/${componentId}/export`), { filters, state }, {
270
- key: 'url',
275
+ key: null,
271
276
  ...config,
272
277
  });
273
278
  }
274
279
  // Exports EVERY component on the report — or on one page — server-side and
275
280
  // resolves to a presigned URL for a zip of one CSV per component, foldered by
276
- // page. `filters` carries the resolved values for every component together;
277
- // each one picks out its own. Same keep-alive contract as exportComponent,
281
+ // page, plus an 'Export Notes.txt'. `filters` carries the resolved values for
282
+ // every component together; each one picks out its own. `frequency` is the
283
+ // report's group-by grain (the server re-buckets only time-series charts to
284
+ // it), and `filterSummary` is the filter bar as the viewer reads it, written
285
+ // verbatim into the notes file. Same keep-alive contract as exportComponent,
278
286
  // over a longer run; consume through FsProcess.download.
279
- exportReport(reportId, filters = [], pageId = null, config = {}) {
280
- return this._api.post(this._path(`${reportId}/export`), { filters, ...(pageId ? { pageId } : {}) }, {
287
+ exportReport(reportId, filters = [], pageId = null, frequency = null, filterSummary = [], config = {}) {
288
+ return this._api.post(this._path(`${reportId}/export`), {
289
+ filters,
290
+ ...(pageId ? { pageId } : {}),
291
+ ...(frequency ? { frequency } : {}),
292
+ filterSummary,
293
+ }, {
281
294
  key: 'url',
282
295
  ...config,
283
296
  });
@@ -540,21 +553,53 @@ function dateBoundLabel(value) {
540
553
  const date = parseISO(bound);
541
554
  return Number.isNaN(date.getTime()) ? String(value) : format(date, 'MMM d, yyyy');
542
555
  }
556
+ // A stored yyyy-MM-dd boundary as a local Date for a date picker, or null.
557
+ // parseISO reads a bare Y-M-D as LOCAL midnight, so the picker shows the day
558
+ // that was stored, whatever the browser's zone.
559
+ function dateFromBound(value) {
560
+ if (!value) {
561
+ return null;
562
+ }
563
+ const date = parseISO(value.slice(0, 10));
564
+ return Number.isNaN(date.getTime()) ? null : date;
565
+ }
566
+ // A picked Date as a yyyy-MM-dd boundary for storage, or null. Uses the local
567
+ // calendar day (format, not toISOString) so the boundary is the day that was
568
+ // picked, regardless of the browser's zone.
569
+ function dateToBound(value) {
570
+ return value instanceof Date && !Number.isNaN(value.getTime())
571
+ ? format(value, 'yyyy-MM-dd')
572
+ : null;
573
+ }
574
+ // A relative date-range preset key as a sentence-case label, derived from the
575
+ // camelCase key itself ('last12Months' reads 'Last 12 months', 'thisWeek'
576
+ // reads 'This week'), so no list of presets is mirrored here and a key the
577
+ // server adds later still reads sensibly.
578
+ function relativeLabel(relative) {
579
+ const words = relative
580
+ .replace(/([a-z])([A-Z0-9])/g, '$1 $2')
581
+ .replace(/(\d)([A-Z])/g, '$1 $2')
582
+ .toLowerCase();
583
+ return words.charAt(0).toUpperCase() + words.slice(1);
584
+ }
543
585
  // Maps a report filter GROUP to an fs-filter config item, and reads fs-filter
544
586
  // query values back into per-group FilterGroupValues. Shared by the report-bar
545
587
  // fs-filter and the per-component fs-filter so both render through the same
546
588
  // library and write into ReportFilterStateService identically. Items are named
547
589
  // `g<groupId>` so the query keys map straight back to the group.
548
- //
549
- // (Lists keep their own filters in FsList and name items `f<filterId>`; this is
550
- // the group-keyed path for charts/KPIs and the report bar.)
551
- // The query key fs-filter uses for a group's item(s).
552
- const itemName = (group) => `g${group.id}`;
590
+ // The prefix of a group's item name, and of its key in the report URL.
591
+ const GROUP_PARAM_PREFIX = 'g';
592
+ // The query key fs-filter uses for a group's item(s), and the report URL's key
593
+ // for the group's value (report-url-state.ts). One naming for both.
594
+ function groupItemName(group) {
595
+ return `${GROUP_PARAM_PREFIX}${group.id}`;
596
+ }
553
597
  // The query key for the report's group-by control. Fixed rather than derived
554
598
  // from an id because a report has exactly one — and it is NOT a filter group,
555
599
  // so it is read straight off the change query instead of through
556
- // groupValuesFromQuery. Client-side only: the grain travels to the server as
557
- // the data request's `state.frequency`.
600
+ // groupValuesFromQuery. It is also the report URL's key for the grain
601
+ // (report-url-state.ts). The grain travels to the server as the data
602
+ // request's `state.frequency`.
558
603
  const GROUP_BY_ITEM = 'groupBy';
559
604
  // A select group's distinct options, as { name, value } pairs. The server sends
560
605
  // { value, label }: the value is what the filter matches on (an entity's stable
@@ -598,7 +643,7 @@ function matchOptions(options$, keyword) {
598
643
  // Build the fs-filter config item for a group, seeding its default from the
599
644
  // current session value so authored/relative defaults show selected on open.
600
645
  function filterItemForGroup(group, reportData, reportId, initial) {
601
- const name = itemName(group);
646
+ const name = groupItemName(group);
602
647
  const label = group.label || group.filters?.[0]?.filterColumn || '';
603
648
  switch (group.type) {
604
649
  case 'dateRange':
@@ -701,7 +746,7 @@ function groupBySignature(report) {
701
746
  // ReportFilterStateService.setValue.
702
747
  function groupValuesFromQuery(query, groups) {
703
748
  return groups.map((group) => {
704
- const name = itemName(group);
749
+ const name = groupItemName(group);
705
750
  if (group.type === 'dateRange') {
706
751
  const start = query[`${name}From`];
707
752
  const end = query[`${name}To`];
@@ -733,6 +778,305 @@ function selectQueryValues(raw) {
733
778
  return String(raw).split(',');
734
779
  }
735
780
 
781
+ // Report weeks start on Monday. This is a rule, not a setting: the server's
782
+ // weekly time buckets (ReportTimeBucket::getPeriodStart() at the Week grain)
783
+ // and its relative date presets (ReportRelativeRangeEnum) both start the week
784
+ // on Monday, so the viewer's thisWeek/lastWeek presets must too, or a weekly
785
+ // chart's buckets would straddle the range its own filter names.
786
+ const REPORT_WEEK_STARTS_ON = 1;
787
+
788
+ // Measure-value formatting shared by the KPI renderer and the exports — the
789
+ // config's `format` (number | percent | currency) and `precision` mean the same
790
+ // thing on screen, in PowerPoint and in PDF.
791
+ // percent takes a 0-1 ratio and shows it as a percentage: 0.3 reads 30%. With
792
+ // no precision it shows at most one decimal (0.037 reads 3.7%); a named
793
+ // precision counts the decimals of the percentage, not of the ratio, so
794
+ // precision 1 shows 0.3 as 30.0%.
795
+ //
796
+ // When the measure names a precision it is pinned as BOTH the minimum and the
797
+ // maximum, so a column of rates lines up instead of each value showing however
798
+ // many decimals it happens to need. Undefined leaves each format's own default
799
+ // alone.
800
+ function getFractionDigits(precision) {
801
+ return Number.isFinite(precision)
802
+ ? { minimumFractionDigits: precision, maximumFractionDigits: precision }
803
+ : {};
804
+ }
805
+ function formatMeasureValue(value, format, precision) {
806
+ if (value === null || value === undefined || value === '') {
807
+ return '—';
808
+ }
809
+ const numeric = Number(value);
810
+ if (!Number.isFinite(numeric)) {
811
+ return String(value);
812
+ }
813
+ const fractionDigits = getFractionDigits(precision);
814
+ switch (format) {
815
+ case 'percent':
816
+ return new Intl.NumberFormat(undefined, {
817
+ style: 'percent',
818
+ maximumFractionDigits: 1,
819
+ ...fractionDigits,
820
+ }).format(numeric);
821
+ case 'currency':
822
+ return new Intl.NumberFormat(undefined, {
823
+ style: 'currency',
824
+ currency: 'USD',
825
+ // Whole dollars unless the measure asks for decimals — cents were
826
+ // unrepresentable before, because this was hardcoded to 0.
827
+ maximumFractionDigits: 0,
828
+ ...fractionDigits,
829
+ }).format(numeric);
830
+ default:
831
+ return new Intl.NumberFormat(undefined, fractionDigits).format(numeric);
832
+ }
833
+ }
834
+ // The axis peak at which a native chart's value axis gains thousands
835
+ // separators. From here up the ticks are whole numbers in practice; below it
836
+ // neither the screen nor PowerPoint's 'General' shows a separator.
837
+ const AXIS_SEPARATOR_THRESHOLD = 1000;
838
+ // PowerPoint's number format for a native chart's value axis, matching what the
839
+ // on-screen axis prints by default: thousands separators with each tick's own
840
+ // decimals. Excel has no "General with separators", so an axis reaching 1,000
841
+ // (where ticks are whole numbers) gets '#,##0', and a smaller range keeps
842
+ // 'General' so fractional ticks (0.25, 0.5) are not rounded away.
843
+ //
844
+ // The peak counts a missing value as 0. A stacked chart's peak is its tallest
845
+ // stack: the largest sum, per category, of every series' absolute value.
846
+ function chartAxisFormatCode(series, stacked) {
847
+ const magnitudes = series.map((values) => values.map((value) => Math.abs(value ?? 0)));
848
+ const peak = stacked
849
+ ? Math.max(0, ...(magnitudes[0] ?? []).map((_, index) => magnitudes
850
+ .reduce((sum, values) => sum + (values[index] ?? 0), 0)))
851
+ : Math.max(0, ...magnitudes.flat());
852
+ return peak >= AXIS_SEPARATOR_THRESHOLD ? '#,##0' : 'General';
853
+ }
854
+ // The zone every date in a report is read in. The server resolves it (the
855
+ // report's own zone, else the application's default reporting zone) and sends
856
+ // it as `effectiveTimezone`; the browser's zone is only the fallback for a
857
+ // server too old to send the field. This is the one place that fallback lives,
858
+ // so every date the viewer renders or resolves agrees with every other.
859
+ function resolveReportTimezone(report) {
860
+ return report.effectiveTimezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
861
+ }
862
+ const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
863
+ const DATE_TIME_PATTERN = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(\.\d+)?$/;
864
+ // A report row's date cell as a calendar day in the report's zone.
865
+ //
866
+ // Report rows are raw database strings, not serialized models, so their shape
867
+ // is the wire contract: a DATE column arrives as 'YYYY-MM-DD' and a
868
+ // date-and-time column as 'YYYY-MM-DD HH:MM:SS' in UTC. Reading that shape
869
+ // here does not re-derive a server rule; it only stops the browser guessing.
870
+ // - A calendar day is never shifted: parseISO reads it as local midnight, so
871
+ // the day printed is the day stored, whatever the zone.
872
+ // - An instant is pinned to UTC with an explicit 'Z', then shown as the day it
873
+ // fell on in the report's zone, the same day the filters and time buckets use.
874
+ // - Anything else is shown as it came, and so is a value that has the shape
875
+ // but names no real day (a zero date '0000-00-00', or '2026-02-30'): one
876
+ // bad cell must not throw out of a pipe or abort an export.
877
+ function formatReportDate(value, timezone) {
878
+ if (value === null || value === undefined || value === '') {
879
+ return '';
880
+ }
881
+ const text = String(value);
882
+ if (DATE_ONLY_PATTERN.test(text)) {
883
+ const day = parseISO(text);
884
+ return isValid(day) ? format(day, 'MMM d, yyyy') : text;
885
+ }
886
+ const dateTime = DATE_TIME_PATTERN.exec(text);
887
+ if (!dateTime) {
888
+ return text;
889
+ }
890
+ const instant = parseISO(`${dateTime[1]}T${dateTime[2]}Z`);
891
+ if (!isValid(instant)) {
892
+ return text;
893
+ }
894
+ // An unknown zone makes date-fns-tz throw; UTC still names a real day.
895
+ try {
896
+ return formatInTimeZone(instant, timezone, 'MMM d, yyyy');
897
+ }
898
+ catch {
899
+ return formatInTimeZone(instant, 'UTC', 'MMM d, yyyy');
900
+ }
901
+ }
902
+ // A short, human label for a zone: its city plus the current abbreviation,
903
+ // e.g. 'America/New_York' reads 'New York (EDT)'. UTC stays 'UTC', and an
904
+ // unknown zone is shown as its id. An 'Etc/' zone names no city, and its POSIX
905
+ // sign is the reverse of the offset ('Etc/GMT+5' is GMT-5), so it reads as the
906
+ // abbreviation alone: 'Etc/UTC' is 'UTC', 'Etc/GMT+5' is 'GMT-5'.
907
+ function getTimezoneLabel(timezone) {
908
+ if (timezone === 'UTC') {
909
+ return timezone;
910
+ }
911
+ const segments = timezone.split('/');
912
+ try {
913
+ const abbreviation = formatInTimeZone(new Date(), timezone, 'zzz');
914
+ return segments[0] === 'Etc'
915
+ ? abbreviation
916
+ : `${segments[segments.length - 1].replace(/_/g, ' ')} (${abbreviation})`;
917
+ }
918
+ catch {
919
+ return timezone;
920
+ }
921
+ }
922
+ // "Now" as a Date whose local fields read the wall clock in the given zone, so
923
+ // date-fns calendar arithmetic (startOfWeek, startOfMonth, ...) and
924
+ // dateBoundString() land on the zone's calendar day rather than the browser's.
925
+ // An unknown zone falls back to the browser's clock.
926
+ function getZonedNow(timezone) {
927
+ const now = new Date();
928
+ try {
929
+ const zoned = toZonedTime(now, timezone);
930
+ return Number.isNaN(zoned.getTime()) ? now : zoned;
931
+ }
932
+ catch {
933
+ return now;
934
+ }
935
+ }
936
+
937
+ // The report viewer's state in its URL: a pure codec, with no knowledge of a
938
+ // loaded report. ReportFilterStateService decides WHAT is carried (only values
939
+ // that differ from the authored defaults) and checks what is read back against
940
+ // the report; this file only turns values into query params and back.
941
+ //
942
+ // ?page=7&groupBy=week&g12=last30Days&g15=3,5&g16=2026-01-01..&s31=-create_date
943
+ //
944
+ // - g<groupId>: one key per filter group. A dateRange is its relative preset
945
+ // name when it carries one, else 'YYYY-MM-DD..YYYY-MM-DD' (either side may be
946
+ // empty); a select is its values joined by ','; a keyword is its text. An
947
+ // empty value is an explicit clear of a group that has a default.
948
+ // - s<componentId>: a list's sort column, '-' prefixed when descending.
949
+ // - groupBy: the group-by grain (GROUP_BY_ITEM).
950
+ // The page key is the host's (FsAiReportComponent.pageQueryParam) and is
951
+ // written beside these, never by this codec.
952
+ // The prefix of a list's sort key.
953
+ const LIST_SORT_PARAM_PREFIX = 's';
954
+ // Between the two days of an encoded fixed date range.
955
+ const DATE_RANGE_SEPARATOR = '..';
956
+ // A key this codec owns: a group value or a list sort. Any such key the
957
+ // snapshot no longer carries is removed from the URL.
958
+ const OWNED_PARAM = new RegExp(`^[${GROUP_PARAM_PREFIX}${LIST_SORT_PARAM_PREFIX}]\\d+$`);
959
+ const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
960
+ // The viewer-state params of a URL as raw strings. Keys this codec does not
961
+ // own are ignored.
962
+ function readReportUrlParams(params) {
963
+ const groups = new Map();
964
+ const listSorts = new Map();
965
+ for (const key of params.keys) {
966
+ if (!OWNED_PARAM.test(key)) {
967
+ continue;
968
+ }
969
+ const id = Number(key.slice(1));
970
+ const raw = params.get(key) ?? '';
971
+ if (key.startsWith(GROUP_PARAM_PREFIX)) {
972
+ groups.set(id, raw);
973
+ }
974
+ else {
975
+ listSorts.set(id, raw);
976
+ }
977
+ }
978
+ return { groups, listSorts, groupBy: params.get(GROUP_BY_ITEM) };
979
+ }
980
+ // A group value read back from its URL param. null is an explicit clear (an
981
+ // empty param). undefined means the param is unusable for a group of this type
982
+ // (an unknown preset name, a malformed day, a wrong shape) and is ignored.
983
+ // `resolveRelative` turns a preset name into today's range for it, or null for
984
+ // a name it does not know.
985
+ function decodeFilterValue(type, raw, resolveRelative) {
986
+ if (raw === '') {
987
+ return null;
988
+ }
989
+ switch (type) {
990
+ case 'dateRange':
991
+ return raw.includes(DATE_RANGE_SEPARATOR)
992
+ ? decodeFixedRange(raw)
993
+ : decodePreset(raw, resolveRelative);
994
+ case 'select': {
995
+ const values = raw.split(',').filter((value) => value !== '');
996
+ return values.length ? { values } : undefined;
997
+ }
998
+ case 'keyword':
999
+ return raw.trim() ? { value: raw } : undefined;
1000
+ default:
1001
+ return undefined;
1002
+ }
1003
+ }
1004
+ // A group value as its URL param. The shape says how: a preset name, then a
1005
+ // date range, then select values, then keyword text. null (a cleared group)
1006
+ // is the empty param.
1007
+ function encodeFilterValue(value) {
1008
+ if (!value) {
1009
+ return '';
1010
+ }
1011
+ if (value.preset) {
1012
+ return value.preset;
1013
+ }
1014
+ if (value.start || value.end) {
1015
+ return [dateBoundString(value.start) ?? '', dateBoundString(value.end) ?? '']
1016
+ .join(DATE_RANGE_SEPARATOR);
1017
+ }
1018
+ if (value.values) {
1019
+ return value.values.map((item) => String(item)).join(',');
1020
+ }
1021
+ return value.value ?? '';
1022
+ }
1023
+ function encodeSort(sort) {
1024
+ return `${sort.direction === 'desc' ? '-' : ''}${sort.column}`;
1025
+ }
1026
+ // A list sort read back from its URL param, or null when it names no column.
1027
+ // Whether the column is one the list can sort by is the caller's check.
1028
+ function decodeSort(raw) {
1029
+ const descending = raw.startsWith('-');
1030
+ const column = descending ? raw.slice(1) : raw;
1031
+ return column
1032
+ ? { column, direction: descending ? 'desc' : 'asc' }
1033
+ : null;
1034
+ }
1035
+ // The query params that make the URL carry `snapshot`: every snapshot key is
1036
+ // set, and each owned key (or the grain) that `current` carries but the
1037
+ // snapshot does not is set to null, which removes it under the router's
1038
+ // 'merge' handling. Keys this codec does not own are left alone.
1039
+ function buildReportUrlParams(snapshot, current) {
1040
+ const params = {};
1041
+ for (const key of current.keys) {
1042
+ if (OWNED_PARAM.test(key) || key === GROUP_BY_ITEM) {
1043
+ params[key] = null;
1044
+ }
1045
+ }
1046
+ snapshot.groups.forEach((value, groupId) => {
1047
+ params[groupItemName({ id: groupId })] = encodeFilterValue(value);
1048
+ });
1049
+ snapshot.listSorts.forEach((sort, componentId) => {
1050
+ params[`${LIST_SORT_PARAM_PREFIX}${componentId}`] = encodeSort(sort);
1051
+ });
1052
+ if (snapshot.frequency) {
1053
+ params[GROUP_BY_ITEM] = snapshot.frequency;
1054
+ }
1055
+ return params;
1056
+ }
1057
+ // 'YYYY-MM-DD..YYYY-MM-DD', either side optional but not both. Each side must
1058
+ // be a real calendar day.
1059
+ function decodeFixedRange(raw) {
1060
+ const sides = raw.split(DATE_RANGE_SEPARATOR);
1061
+ if (sides.length !== 2 || (!sides[0] && !sides[1])) {
1062
+ return undefined;
1063
+ }
1064
+ const [start, end] = sides.map((side) => side ? decodeDay(side) : null);
1065
+ if (start === undefined || end === undefined) {
1066
+ return undefined;
1067
+ }
1068
+ return { start, end };
1069
+ }
1070
+ // One side of a fixed range as a local Date, or undefined when it is not a
1071
+ // real Y-M-D day.
1072
+ function decodeDay(side) {
1073
+ return DAY_PATTERN.test(side) ? (dateFromBound(side) ?? undefined) : undefined;
1074
+ }
1075
+ function decodePreset(name, resolveRelative) {
1076
+ const range = resolveRelative(name);
1077
+ return range ? { start: range.start, end: range.end, preset: name } : undefined;
1078
+ }
1079
+
736
1080
  // Whether two stored filter-group defaults are the same. Compared on the fields a
737
1081
  // default can actually carry, so key order in the payload can't read as a change.
738
1082
  function sameDefault(a, b) {
@@ -746,6 +1090,18 @@ function sameDefault(a, b) {
746
1090
  && (a.values ?? []).length === (b.values ?? []).length
747
1091
  && (a.values ?? []).every((value, index) => value === (b.values ?? [])[index]);
748
1092
  }
1093
+ // Whether two list sorts order the rows the same way. No direction reads as
1094
+ // ascending, the way the list applies it.
1095
+ function sameSort(a, b) {
1096
+ return !!b
1097
+ && a.column === b.column
1098
+ && (a.direction ?? 'asc') === (b.direction ?? 'asc');
1099
+ }
1100
+ // Whether two date-range values cover the same calendar days.
1101
+ function sameDays(a, b) {
1102
+ return dateBoundString(a.start) === dateBoundString(b.start)
1103
+ && dateBoundString(a.end) === dateBoundString(b.end);
1104
+ }
749
1105
  // A stable signature for a group's stored default. The report toolbar keys its
750
1106
  // fs-filter on this, because fs-filter reads its config once at init — without a
751
1107
  // key change it keeps rendering the values it was created with even after the
@@ -763,72 +1119,107 @@ function defaultSignature(group) {
763
1119
  (groupDefault.values ?? []).join(','),
764
1120
  ].join('~');
765
1121
  }
766
- // Session filter state for the open report. Filter VALUES are never persisted
767
- // — this service holds them per filter GROUP (the propagation unit), resolves
768
- // them into per-component member-filter values for each data request, and
769
- // notifies the components whose groups changed so only they refetch.
1122
+ // Viewer filter state for the open report. Filter VALUES are never saved to
1123
+ // the report — this service holds them per filter GROUP (the propagation unit),
1124
+ // resolves them into per-component member-filter values for each data request,
1125
+ // and notifies the components whose groups changed so only they refetch.
1126
+ //
1127
+ // It is also the one source of the viewer state the report URL carries (group
1128
+ // values, the group-by grain, list sorts): urlSnapshot() says what differs from
1129
+ // the authored defaults, init() restores it, and urlChanges() tells the host's
1130
+ // single URL writer when to write.
770
1131
  //
771
- // Also session-scoped: per-filter enable/disable toggles from a component's
772
- // filter menu (the `enabled` column is the authored default; a viewer's toggle
773
- // shouldn't write to the report).
1132
+ // Session-only, never in the URL: per-filter enable/disable toggles from a
1133
+ // component's filter menu (the `enabled` column is the authored default; a
1134
+ // viewer's toggle shouldn't write to the report).
774
1135
  class ReportFilterStateService {
1136
+ // The open report's effective zone (resolveReportTimezone). Relative presets
1137
+ // resolve against today in it, and date cells render in it.
1138
+ timezone = signal('UTC');
775
1139
  _values = new Map();
776
1140
  _groups = new Map();
777
1141
  _sessionDisabled = new Set();
778
- // The report's group-by control: a single session-wide bucket override for all
1142
+ // Groups that have a default the viewer cleared. Without this a reload of the
1143
+ // report (a chat edit, a dialog) would find no value for the group and seed
1144
+ // its default straight back. A change to the stored default releases it.
1145
+ _cleared = new Set();
1146
+ // The report's group-by control: a single report-wide bucket override for all
779
1147
  // time-series charts. Unset = each chart uses its authored granularity. Never
780
- // persisted (it dies with this report view), and carried in the data request's
1148
+ // saved to the report (the URL carries it), and carried in the data request's
781
1149
  // `state`, so it propagates through to the PDF/PowerPoint exports too. Named
782
1150
  // for the `frequency` wire key it travels under, not for the control's label.
783
1151
  _frequency = null;
784
1152
  _frequencyChanged$ = new Subject();
1153
+ // Each list's viewer-chosen sort, by component id. Held here rather than only
1154
+ // in the list's FsList so it survives the list being recreated (a page switch,
1155
+ // a reload after an edit) and so every consumer of viewer state reads one place.
1156
+ _listSorts = new Map();
785
1157
  // Emits the group id that changed; components listen filtered to their own
786
1158
  // group ids. null = everything changed (re-init).
787
1159
  _changed$ = new Subject();
1160
+ // Fires whenever anything the URL carries may have changed.
1161
+ _urlChanged$ = new Subject();
788
1162
  // Seed groups + their defaults when a report loads. Existing session values
789
1163
  // for still-existing groups survive a structure re-fetch (a chat edit
790
- // shouldn't reset the viewer's filters) — UNLESS the group's stored default
791
- // itself changed, which only happens when an operator saved a new default in
792
- // report settings. That is an explicit instruction to re-seed, so it wins over
793
- // the surviving session value; without it the bar keeps showing whatever it was
794
- // first seeded with until a full page reload.
795
- init(report) {
1164
+ // shouldn't reset the viewer's filters), and so does a group the viewer
1165
+ // cleared — UNLESS the group's stored default itself changed, which only
1166
+ // happens when an operator saved a new default in report settings or the
1167
+ // agent set one. That is an explicit instruction to re-seed, so it wins over
1168
+ // the surviving session value; without it the bar keeps showing whatever it
1169
+ // was first seeded with until a full page reload.
1170
+ //
1171
+ // A change of the report's zone moves "today", so a value still carrying its
1172
+ // group default's preset name is resolved again in the new zone. A value the
1173
+ // viewer picked, or one carrying another preset name, is kept as it is.
1174
+ //
1175
+ // `restore` is the viewer state read from the URL. The host passes it only on
1176
+ // the first load of a report; a reload after a chat or dialog edit passes
1177
+ // null, so the in-memory state wins. Params that name a group, list or column
1178
+ // the report no longer has, or that do not decode, are ignored.
1179
+ init(report, restore = null) {
1180
+ const previousTimezone = this.timezone();
1181
+ // Set first: the defaults seeded below resolve their relative presets in it.
1182
+ this.timezone.set(resolveReportTimezone(report));
1183
+ const timezoneChanged = previousTimezone !== this.timezone();
796
1184
  const previousGroups = this._groups;
797
1185
  this._groups = new Map();
798
- const validIds = new Set();
799
1186
  for (const group of report.filterGroups ?? []) {
800
1187
  this._groups.set(group.id, group);
801
- validIds.add(group.id);
802
- const defaultChanged = !sameDefault(previousGroups.get(group.id)?.config?.default ?? null, group.config?.default ?? null);
803
- if (!this._values.has(group.id) || defaultChanged) {
804
- const defaultValue = this._defaultValue(group);
805
- if (defaultValue) {
806
- this._values.set(group.id, defaultValue);
807
- }
808
- else if (defaultChanged) {
809
- // The default was cleared, so the value it seeded goes with it.
810
- this._values.delete(group.id);
811
- }
812
- }
1188
+ this._seed(group, previousGroups.get(group.id) ?? null, timezoneChanged);
813
1189
  }
814
- for (const groupId of [...this._values.keys()]) {
815
- if (!validIds.has(groupId)) {
816
- this._values.delete(groupId);
817
- }
1190
+ if (restore) {
1191
+ this._restore(report, restore);
818
1192
  }
1193
+ this._prune(report);
819
1194
  this._changed$.next(null);
1195
+ this._urlChanged$.next();
820
1196
  }
821
1197
  value(groupId) {
822
1198
  return this._values.get(groupId);
823
1199
  }
1200
+ // A bar's value for a group. A date range keeps the stored preset name only
1201
+ // while its days are still the stored days: the bars re-set every group they
1202
+ // show whenever any one of them changes, and an untouched rolling range must
1203
+ // keep its name rather than turn into two fixed dates. Any other range, a
1204
+ // picker shortcut included, is stored as the fixed dates it shows.
824
1205
  setValue(groupId, value) {
825
1206
  if (value === null) {
826
1207
  this._values.delete(groupId);
1208
+ if (this._groups.get(groupId)?.config?.default) {
1209
+ this._cleared.add(groupId);
1210
+ }
827
1211
  }
828
1212
  else {
829
- this._values.set(groupId, value);
1213
+ this._values.set(groupId, this._withPreset(this._values.get(groupId), value));
1214
+ this._cleared.delete(groupId);
830
1215
  }
831
1216
  this._changed$.next(groupId);
1217
+ this._urlChanged$.next();
1218
+ }
1219
+ // A relative preset name as today's range in the report's zone, or null for
1220
+ // a name it does not know. The URL codec decodes preset names through this.
1221
+ resolveRelative(name) {
1222
+ return this._relativeRange(name);
832
1223
  }
833
1224
  // The active group-by grain, or null when the viewer hasn't chosen one.
834
1225
  frequency() {
@@ -837,6 +1228,7 @@ class ReportFilterStateService {
837
1228
  setFrequency(frequency) {
838
1229
  this._frequency = frequency;
839
1230
  this._frequencyChanged$.next();
1231
+ this._urlChanged$.next();
840
1232
  }
841
1233
  // Fires whenever the group-by grain changes — every time-series chart listens
842
1234
  // so it refetches and re-buckets (the override isn't scoped to any filter
@@ -844,6 +1236,54 @@ class ReportFilterStateService {
844
1236
  frequencyChanges() {
845
1237
  return this._frequencyChanged$.asObservable();
846
1238
  }
1239
+ // The sort the viewer last fetched a list with, or null when it hasn't sorted
1240
+ // (the list then falls back to its authored sort).
1241
+ listSort(componentId) {
1242
+ return this._listSorts.get(componentId) ?? null;
1243
+ }
1244
+ // Records a list's sort. Deliberately emits no change: the list sets this from
1245
+ // the fetch that already ran with that sort, so a notification would only make
1246
+ // it refetch the same rows. Only the URL hears about it.
1247
+ setListSort(componentId, sort) {
1248
+ if (sort) {
1249
+ this._listSorts.set(componentId, sort);
1250
+ }
1251
+ else {
1252
+ this._listSorts.delete(componentId);
1253
+ }
1254
+ this._urlChanged$.next();
1255
+ }
1256
+ // Fires whenever the viewer state the URL carries may have changed: a group
1257
+ // value, the grain, a list sort, or a (re)load of the report.
1258
+ urlChanges() {
1259
+ return this._urlChanged$.asObservable();
1260
+ }
1261
+ // The viewer state the URL should carry for `report`: only what differs from
1262
+ // the authored defaults, so an untouched report has a bare URL and keeps
1263
+ // following its defaults when they change. A group the viewer cleared is
1264
+ // null. A date range is compared on its days against its default resolved
1265
+ // today, so a value that is the default under another preset name (thisYear
1266
+ // against a yearToDate default) is still the default.
1267
+ urlSnapshot(report) {
1268
+ const groups = new Map();
1269
+ for (const group of this._groups.values()) {
1270
+ const value = this._values.get(group.id);
1271
+ if (this._cleared.has(group.id)) {
1272
+ groups.set(group.id, null);
1273
+ }
1274
+ else if (value && !this._isDefault(group, value)) {
1275
+ groups.set(group.id, value);
1276
+ }
1277
+ }
1278
+ const listSorts = new Map();
1279
+ for (const component of this._listComponents(report)) {
1280
+ const sort = this._listSorts.get(component.id);
1281
+ if (sort && !sameSort(sort, component.config?.sort ?? null)) {
1282
+ listSorts.set(component.id, sort);
1283
+ }
1284
+ }
1285
+ return { groups, listSorts, frequency: this._frequency };
1286
+ }
847
1287
  isFilterDisabled(filterId) {
848
1288
  return this._sessionDisabled.has(filterId);
849
1289
  }
@@ -873,28 +1313,28 @@ class ReportFilterStateService {
873
1313
  continue;
874
1314
  }
875
1315
  const value = this._values.get(filter.filterGroupId);
876
- if (!value) {
877
- continue;
878
- }
879
- const entry = { filterId: filter.id };
880
- if (value.start) {
881
- entry.start = dateBoundString(value.start);
882
- }
883
- if (value.end) {
884
- entry.end = dateBoundString(value.end);
885
- }
886
- if (value.values?.length) {
887
- entry.values = value.values;
888
- }
889
- if (value.value?.trim()) {
890
- entry.value = value.value.trim();
891
- }
892
- if (entry.start || entry.end || entry.values || entry.value) {
893
- resolved.push(entry);
1316
+ const fields = value ? this._resolvedFields(value) : null;
1317
+ if (fields) {
1318
+ resolved.push({ filterId: filter.id, ...fields });
894
1319
  }
895
1320
  }
896
1321
  return resolved;
897
1322
  }
1323
+ // What the viewer has set, one entry per filter group, for the report chat:
1324
+ // the agent reads it to know what is on screen. Group order, not the order
1325
+ // the values were set in, so the same state always reads the same. A group
1326
+ // with nothing in it is left out, and a saved default counts until the
1327
+ // viewer changes it. Per-filter toggles are a component's own business, so
1328
+ // they are not part of it.
1329
+ viewerFilters() {
1330
+ return [...this._values.entries()]
1331
+ .sort(([a], [b]) => a - b)
1332
+ .map(([groupId, value]) => {
1333
+ const fields = this._resolvedFields(value);
1334
+ return fields ? { groupId, ...fields } : null;
1335
+ })
1336
+ .filter((entry) => entry !== null);
1337
+ }
898
1338
  // Every component's resolved values in one array — what a report-level export
899
1339
  // carries so each file in the archive is filtered exactly as the screen is.
900
1340
  // Filter ids are unique per component, so the backend picks each component's
@@ -906,10 +1346,148 @@ class ReportFilterStateService {
906
1346
  .flatMap((page) => (page.components ?? [])
907
1347
  .flatMap((component) => this.resolveForComponent(component)));
908
1348
  }
1349
+ // A group value as the fields a request carries: date bounds as calendar
1350
+ // days, select values when any are picked, keyword text trimmed. Null when
1351
+ // all of them are empty. The preset name is URL state only, so it is left
1352
+ // behind.
1353
+ _resolvedFields(value) {
1354
+ const fields = {};
1355
+ if (value.start) {
1356
+ fields.start = dateBoundString(value.start);
1357
+ }
1358
+ if (value.end) {
1359
+ fields.end = dateBoundString(value.end);
1360
+ }
1361
+ if (value.values?.length) {
1362
+ fields.values = value.values;
1363
+ }
1364
+ if (value.value?.trim()) {
1365
+ fields.value = value.value.trim();
1366
+ }
1367
+ return fields.start || fields.end || fields.values || fields.value
1368
+ ? fields
1369
+ : null;
1370
+ }
1371
+ // Seeds one group on (re)load; see init(). `previous` is the group as the
1372
+ // last load had it, or null when it is new.
1373
+ _seed(group, previous, timezoneChanged) {
1374
+ const defaultChanged = !sameDefault(previous?.config?.default ?? null, group.config?.default ?? null);
1375
+ if (defaultChanged) {
1376
+ this._cleared.delete(group.id);
1377
+ }
1378
+ if (this._cleared.has(group.id)) {
1379
+ return;
1380
+ }
1381
+ const value = this._values.get(group.id);
1382
+ const presetToResolve = timezoneChanged
1383
+ && !!value?.preset
1384
+ && value.preset === group.config?.default?.relative;
1385
+ if (value && !defaultChanged && !presetToResolve) {
1386
+ return;
1387
+ }
1388
+ const defaultValue = this._defaultValue(group);
1389
+ if (defaultValue) {
1390
+ this._values.set(group.id, defaultValue);
1391
+ }
1392
+ else if (defaultChanged) {
1393
+ // The default was cleared, so the value it seeded goes with it.
1394
+ this._values.delete(group.id);
1395
+ }
1396
+ }
1397
+ // Applies the viewer state read from the URL on top of the seeded defaults.
1398
+ _restore(report, restore) {
1399
+ restore.groups.forEach((raw, groupId) => {
1400
+ const group = this._groups.get(groupId);
1401
+ const value = group
1402
+ ? decodeFilterValue(group.type, raw, (name) => this.resolveRelative(name))
1403
+ : undefined;
1404
+ if (value === null) {
1405
+ this._values.delete(groupId);
1406
+ if (group?.config?.default) {
1407
+ this._cleared.add(groupId);
1408
+ }
1409
+ }
1410
+ else if (value) {
1411
+ this._values.set(groupId, value);
1412
+ this._cleared.delete(groupId);
1413
+ }
1414
+ });
1415
+ const lists = new Map(this._listComponents(report).map((component) => [component.id, component]));
1416
+ restore.listSorts.forEach((raw, componentId) => {
1417
+ const component = lists.get(componentId);
1418
+ const sort = decodeSort(raw);
1419
+ if (component && sort && this._sortableColumns(component).includes(sort.column)) {
1420
+ this._listSorts.set(componentId, sort);
1421
+ }
1422
+ });
1423
+ // The URL is the whole truth on a first load: no grain in it means the
1424
+ // authored ones. The host still drops a grain the report does not offer.
1425
+ this._frequency = GRANULARITY_OPTIONS
1426
+ .find((option) => option.value === restore.groupBy)?.value ?? null;
1427
+ }
1428
+ // Drops state kept for groups and lists the report no longer has.
1429
+ _prune(report) {
1430
+ for (const groupId of [...this._values.keys(), ...this._cleared]) {
1431
+ if (!this._groups.has(groupId)) {
1432
+ this._values.delete(groupId);
1433
+ this._cleared.delete(groupId);
1434
+ }
1435
+ }
1436
+ const listIds = new Set(this._listComponents(report).map((component) => component.id));
1437
+ for (const componentId of [...this._listSorts.keys()]) {
1438
+ if (!listIds.has(componentId)) {
1439
+ this._listSorts.delete(componentId);
1440
+ }
1441
+ }
1442
+ }
1443
+ // The value a bar sets, keeping the stored preset name while the days are
1444
+ // still the stored days (see setValue).
1445
+ _withPreset(stored, value) {
1446
+ const fixed = { ...value };
1447
+ delete fixed.preset;
1448
+ return stored?.preset && sameDays(stored, fixed)
1449
+ ? { ...fixed, preset: stored.preset }
1450
+ : fixed;
1451
+ }
1452
+ // Whether a value is the group's authored default, by type: a date range on
1453
+ // its days (the default resolved today), a select on its values in any
1454
+ // order, a keyword on its text.
1455
+ _isDefault(group, value) {
1456
+ const defaultValue = this._defaultValue(group);
1457
+ if (!defaultValue) {
1458
+ return false;
1459
+ }
1460
+ switch (group.type) {
1461
+ case 'dateRange':
1462
+ return sameDays(value, defaultValue);
1463
+ case 'select': {
1464
+ const values = (value.values ?? []).map(String).sort();
1465
+ const defaults = (defaultValue.values ?? []).map(String).sort();
1466
+ return values.length === defaults.length
1467
+ && values.every((item, index) => item === defaults[index]);
1468
+ }
1469
+ default:
1470
+ return (value.value ?? '').trim() === (defaultValue.value ?? '').trim();
1471
+ }
1472
+ }
1473
+ _listComponents(report) {
1474
+ return (report.pages ?? [])
1475
+ .flatMap((page) => page.components ?? [])
1476
+ .filter((component) => component.type === 'list');
1477
+ }
1478
+ // The columns a list can be sorted by: every authored column, or a
1479
+ // cross-tab's row column only (its pivot columns come and go with the data).
1480
+ _sortableColumns(component) {
1481
+ const config = component.config;
1482
+ if (config?.pivotBy?.column) {
1483
+ return config.rowBy?.column ? [config.rowBy.column] : [];
1484
+ }
1485
+ return (config?.columns ?? []).map((column) => column.column);
1486
+ }
909
1487
  // A group's authored default, mapped to a session value by type. Relative
910
1488
  // date presets resolve against "now" each time the report opens, so a saved
911
- // "last 12 months" stays relative; a fixed range, select values and keyword
912
- // text seed verbatim.
1489
+ // "last 12 months" stays relative and carries its preset name; a fixed
1490
+ // range, select values and keyword text seed verbatim.
913
1491
  _defaultValue(group) {
914
1492
  const groupDefault = group.config?.default;
915
1493
  if (!groupDefault) {
@@ -917,7 +1495,7 @@ class ReportFilterStateService {
917
1495
  }
918
1496
  if (groupDefault.relative) {
919
1497
  const range = this._relativeRange(groupDefault.relative);
920
- return range ? { start: range.start, end: range.end } : null;
1498
+ return range ? { start: range.start, end: range.end, preset: groupDefault.relative } : null;
921
1499
  }
922
1500
  if (groupDefault.start || groupDefault.end) {
923
1501
  return {
@@ -933,22 +1511,28 @@ class ReportFilterStateService {
933
1511
  }
934
1512
  return null;
935
1513
  }
1514
+ // A relative preset as a calendar-day range. "Today" is the day in the
1515
+ // report's effective zone, not the browser's, so every viewer seeds the same
1516
+ // range. The server twin is ReportRelativeRangeEnum (framework/Report/Enum),
1517
+ // and ReportRelativeRangeEnumTest pins the table both sides must produce.
936
1518
  _relativeRange(relative) {
937
- const now = new Date();
1519
+ const now = getZonedNow(this.timezone());
938
1520
  switch (relative) {
939
- // A calendar week, Sunday to Saturday, in whole days — not the rolling
1521
+ // A calendar week, Monday to Sunday (REPORT_WEEK_STARTS_ON, the same week
1522
+ // the server's weekly buckets use), in whole days — not the rolling
940
1523
  // seven days `last7Days` gives, which never lines up with the week a
941
1524
  // partner has already been sent. `lastWeek` is the one a weekly report
942
- // wants: it ends at endOfWeek rather than at `now`, because the week it
943
- // names has closed. `thisWeek` follows every other preset here and runs
944
- // up to now, so it is deliberately a part-finished week.
1525
+ // wants: it ends on the previous week's Sunday rather than at `now`,
1526
+ // because the week it names has closed. `thisWeek` follows every other
1527
+ // preset here and runs up to now, so it is deliberately a part-finished
1528
+ // week (on a Sunday it is the whole Monday-to-Sunday week).
945
1529
  case 'thisWeek':
946
- return { start: startOfWeek(now, { weekStartsOn: 0 }), end: now };
1530
+ return { start: startOfWeek(now, { weekStartsOn: REPORT_WEEK_STARTS_ON }), end: now };
947
1531
  case 'lastWeek': {
948
1532
  const previous = subWeeks(now, 1);
949
1533
  return {
950
- start: startOfWeek(previous, { weekStartsOn: 0 }),
951
- end: endOfWeek(previous, { weekStartsOn: 0 }),
1534
+ start: startOfWeek(previous, { weekStartsOn: REPORT_WEEK_STARTS_ON }),
1535
+ end: endOfWeek(previous, { weekStartsOn: REPORT_WEEK_STARTS_ON }),
952
1536
  };
953
1537
  }
954
1538
  case 'last7Days':
@@ -979,46 +1563,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
979
1563
  type: Injectable
980
1564
  }] });
981
1565
 
982
- // Measure-value formatting shared by the KPI renderer and the exports — the
983
- // config's `format` (number | percent | currency) and `precision` mean the same
984
- // thing on screen, in PowerPoint and in PDF.
985
- // A bare `new Intl.NumberFormat()` defaults to maximumFractionDigits: 3, which
986
- // is why an unconfigured percentage reads 38.298%. When the measure names a
987
- // precision it is pinned as BOTH the minimum and the maximum, so a column of
988
- // rates lines up instead of each value showing however many decimals it happens
989
- // to need. Undefined leaves the locale default alone, so a report authored
990
- // before precision existed renders exactly as it did.
991
- function getFractionDigits(precision) {
992
- return Number.isFinite(precision)
993
- ? { minimumFractionDigits: precision, maximumFractionDigits: precision }
994
- : {};
995
- }
996
- function formatMeasureValue(value, format, precision) {
997
- if (value === null || value === undefined || value === '') {
998
- return '—';
999
- }
1000
- const numeric = Number(value);
1001
- if (!Number.isFinite(numeric)) {
1002
- return String(value);
1003
- }
1004
- const fractionDigits = getFractionDigits(precision);
1005
- switch (format) {
1006
- case 'percent':
1007
- return `${new Intl.NumberFormat(undefined, fractionDigits).format(numeric)}%`;
1008
- case 'currency':
1009
- return new Intl.NumberFormat(undefined, {
1010
- style: 'currency',
1011
- currency: 'USD',
1012
- // Whole dollars unless the measure asks for decimals — cents were
1013
- // unrepresentable before, because this was hardcoded to 0.
1014
- maximumFractionDigits: 0,
1015
- ...fractionDigits,
1016
- }).format(numeric);
1017
- default:
1018
- return new Intl.NumberFormat(undefined, fractionDigits).format(numeric);
1019
- }
1020
- }
1021
-
1022
1566
  // Text blocks — shared by the canvas and both exports, so a block reads the
1023
1567
  // same on screen, in the deck and in the PDF.
1024
1568
  //
@@ -1258,13 +1802,37 @@ const REPORT_WATERFALL_COLORS = {
1258
1802
  total: REPORT_CHART_COLORS[9],
1259
1803
  };
1260
1804
 
1805
+ // The report charts' typeface and axis-label size. The ECharts theme draws with
1806
+ // them, and the option builder measures axis labels with them, so the room it
1807
+ // leaves around the labels matches what is drawn.
1808
+ const REPORT_CHART_FONT_FAMILY = 'Roboto, "Helvetica Neue", sans-serif';
1809
+ const REPORT_AXIS_LABEL_FONT_SIZE = 11;
1810
+
1811
+ // Room for the category axis's title. ECharts 5's containLabel keeps the TICK
1812
+ // labels inside the grid but not the axis name, so without this a vertical
1813
+ // chart's title runs off the bottom edge or under a bottom legend.
1814
+ const AXIS_NAME_ROOM = 20;
1815
+ // ECharts' own default gap between the axis line and its tick labels.
1816
+ const AXIS_LABEL_MARGIN = 8;
1817
+ // Clearance between the longest tick label and a horizontal chart's rotated
1818
+ // title, so the two never touch.
1819
+ const AXIS_NAME_CLEARANCE = 6;
1820
+ // A horizontal chart's category labels are cut short with an ellipsis past
1821
+ // this width (the tooltip still shows them in full), so one long label cannot
1822
+ // squeeze the plot away.
1823
+ const CATEGORY_LABEL_MAX_WIDTH = 160;
1824
+ const LABEL_ELLIPSIS = '…';
1825
+ // The font the theme draws axis labels in, as a canvas font string. Labels are
1826
+ // measured in it the way ECharts measures them for containLabel, so a
1827
+ // horizontal chart's title lands exactly beyond the widest label.
1828
+ const AXIS_LABEL_FONT = `${REPORT_AXIS_LABEL_FONT_SIZE}px ${REPORT_CHART_FONT_FAMILY}`;
1261
1829
  // colorBy applies only to single-dimension bar charts, and only when splitBy
1262
1830
  // isn't already driving multiple series: it colors each bar by its category and
1263
1831
  // shows a legend (one series per category, overlapped to full width).
1264
1832
  //
1265
1833
  // It is the DEFAULT for a categorical bar chart rather than something the config
1266
- // has to ask for. A bar chart whose x-axis is a category — provinces, statuses,
1267
- // clinics — is comparing those categories to each other, and one flat color with
1834
+ // has to ask for. A bar chart whose x-axis is a category — regions, statuses,
1835
+ // stores — is comparing those categories to each other, and one flat color with
1268
1836
  // no legend is the wrong reading of it in essentially every case. Requiring
1269
1837
  // colorBy to opt in meant every such chart rendered monochrome unless whoever
1270
1838
  // wrote the config remembered an optional property, which is a default set the
@@ -1399,11 +1967,11 @@ function buildAxisOption(component, data, chartType) {
1399
1967
  // a combo each type stacks only with its own kind, so a line of a rate never
1400
1968
  // sits on top of the bars' total.
1401
1969
  const series = extracted.series.map((extractedSeries, index) => buildSeries(extractedSeries.name, extractedSeries.values, types[index], config.stacked ? (combo ? `total-${types[index]}` : 'total') : undefined, colorBy && index === 0, combo));
1402
- const categoryAxis = categoryAxisOption(config, data, extracted.categories);
1970
+ const categoryAxis = categoryAxisOption(config, data, extracted.categories, horizontal);
1403
1971
  const valueAxis = { type: 'value' };
1404
1972
  const legendPosition = resolveLegendPosition(config, series.length, colorBy);
1405
1973
  return {
1406
- grid: axisGrid(legendPosition),
1974
+ grid: axisGrid(legendPosition, categoryTitleSide(config, horizontal)),
1407
1975
  legend: legendOption(legendPosition),
1408
1976
  // colorBy charts overlap N mostly-null series, so an axis tooltip would list
1409
1977
  // every category; an item tooltip shows just the hovered bar.
@@ -1415,28 +1983,111 @@ function buildAxisOption(component, data, chartType) {
1415
1983
  }
1416
1984
  // The category axis of an axis chart or a waterfall: the categories in row
1417
1985
  // order, labeled as periods when the rows were bucketed by time.
1418
- function categoryAxisOption(config, data, categories) {
1986
+ //
1987
+ // Horizontal bars read top-down: ECharts draws category 0 at the bottom, so the
1988
+ // axis is inverted to put the first row (the largest of a top-N, a waterfall's
1989
+ // first step) at the top. The rotated title sits just beyond the longest label
1990
+ // rather than at a fixed gap that would put it on top of the labels.
1991
+ function categoryAxisOption(config, data, categories, horizontal) {
1419
1992
  const granularity = bucketGranularity(data);
1420
- return {
1993
+ const periodLabel = periodAxisLabel(granularity, categories);
1994
+ const axis = {
1421
1995
  type: 'category',
1422
1996
  data: categories,
1997
+ inverse: horizontal,
1423
1998
  name: config.xAxis?.label,
1424
1999
  nameLocation: 'middle',
1425
- nameGap: granularity && spansMultipleYears(categories) ? 40 : 28,
1426
- ...periodAxisLabel(granularity, categories),
1427
2000
  };
2001
+ if (!horizontal) {
2002
+ return {
2003
+ ...axis,
2004
+ nameGap: granularity && spansMultipleYears(categories) ? 40 : 28,
2005
+ ...periodLabel,
2006
+ };
2007
+ }
2008
+ // A period label ('May', 'Q2') is always short, so only a plain category is
2009
+ // cut short. The labels are cut here rather than by ECharts' own truncation,
2010
+ // so the title's gap is measured on exactly the text the axis draws.
2011
+ const shown = granularity
2012
+ ? null
2013
+ : new Map(categories.map((category) => [category, truncateAxisLabel(category)]));
2014
+ const labels = shown ? [...shown.values()] : displayedCategoryLabels(categories, granularity);
2015
+ return {
2016
+ ...axis,
2017
+ nameGap: widestAxisLabel(labels) + AXIS_LABEL_MARGIN + AXIS_NAME_CLEARANCE,
2018
+ ...(shown ? { axisLabel: { formatter: (value) => shown.get(value) ?? value } } : periodLabel),
2019
+ };
2020
+ }
2021
+ // The category axis's title side, or null when the axis has no title to make
2022
+ // room for.
2023
+ function categoryTitleSide(config, horizontal) {
2024
+ if (!config.xAxis?.label) {
2025
+ return null;
2026
+ }
2027
+ return horizontal ? 'left' : 'bottom';
2028
+ }
2029
+ // The tick labels as the axis shows them: a bucket's primary period label
2030
+ // ('May', 'Q2'), otherwise the category itself. The year a multi-year axis
2031
+ // prints under a period is left out, as it is never the wider line.
2032
+ function displayedCategoryLabels(categories, granularity) {
2033
+ return granularity
2034
+ ? categories.map((category) => periodLabelParts(category, granularity)?.primary ?? category)
2035
+ : categories;
2036
+ }
2037
+ // The pixel width of the widest label. A label that breaks over several lines
2038
+ // is as wide as its longest line.
2039
+ function widestAxisLabel(labels) {
2040
+ return labels.reduce((widest, label) => label.split('\n').reduce((lineWidest, line) => Math.max(lineWidest, axisLabelWidth(line)), widest), 0);
2041
+ }
2042
+ // A category label cut to CATEGORY_LABEL_MAX_WIDTH, line by line: the longest
2043
+ // start of each line that still fits with the ellipsis after it.
2044
+ function truncateAxisLabel(label) {
2045
+ return label.split('\n').map((line) => {
2046
+ if (axisLabelWidth(line) <= CATEGORY_LABEL_MAX_WIDTH) {
2047
+ return line;
2048
+ }
2049
+ // Code points, so an emoji or other surrogate pair is never split.
2050
+ const characters = Array.from(line);
2051
+ const cut = (length) => `${characters.slice(0, length).join('').trimEnd()}${LABEL_ELLIPSIS}`;
2052
+ let fits = 0;
2053
+ let tooLong = characters.length;
2054
+ while (tooLong - fits > 1) {
2055
+ const length = Math.floor((fits + tooLong) / 2);
2056
+ if (axisLabelWidth(cut(length)) <= CATEGORY_LABEL_MAX_WIDTH) {
2057
+ fits = length;
2058
+ }
2059
+ else {
2060
+ tooLong = length;
2061
+ }
2062
+ }
2063
+ return cut(fits);
2064
+ }).join('\n');
2065
+ }
2066
+ // One line's width in the axis-label font, measured on a canvas the way
2067
+ // ECharts measures it for containLabel. Outside a browser there is no canvas
2068
+ // to measure with, so the line counts as zero width.
2069
+ let axisLabelContext = null;
2070
+ function axisLabelWidth(line) {
2071
+ if (!axisLabelContext && typeof document !== 'undefined') {
2072
+ axisLabelContext = document.createElement('canvas').getContext('2d');
2073
+ }
2074
+ if (!axisLabelContext) {
2075
+ return 0;
2076
+ }
2077
+ axisLabelContext.font = AXIS_LABEL_FONT;
2078
+ return axisLabelContext.measureText(line).width;
1428
2079
  }
1429
2080
  function buildWaterfallOption(component, data) {
1430
2081
  const config = component.config;
1431
2082
  const waterfall = extractWaterfall(component, data);
1432
- const categoryAxis = categoryAxisOption(config, data, waterfall.steps.map((step) => step.category));
1433
- const valueAxis = { type: 'value' };
1434
2083
  const horizontal = config.orientation === 'horizontal';
2084
+ const categoryAxis = categoryAxisOption(config, data, waterfall.steps.map((step) => step.category), horizontal);
2085
+ const valueAxis = { type: 'value' };
1435
2086
  // One visible series, so the legend is hidden unless the config pins it.
1436
2087
  const legendPosition = resolveLegendPosition(config, 1);
1437
2088
  const granularity = bucketGranularity(data);
1438
2089
  return {
1439
- grid: axisGrid(legendPosition),
2090
+ grid: axisGrid(legendPosition, categoryTitleSide(config, horizontal)),
1440
2091
  legend: { ...legendOption(legendPosition), data: [waterfall.name] },
1441
2092
  // Per bar: the invisible base is silent, so only a step answers the hover.
1442
2093
  tooltip: {
@@ -1524,9 +2175,23 @@ function legendOption(position) {
1524
2175
  return { ...base, top: 0 };
1525
2176
  }
1526
2177
  }
1527
- // Reserve plot-edge room on whichever side the legend occupies so it never
1528
- // overlaps the bars/lines; containLabel already accounts for the axis labels.
1529
- function axisGrid(position) {
2178
+ // The plot's grid: room on the legend's side so it never overlaps the
2179
+ // bars/lines, plus room for the category axis's title on its side, which
2180
+ // containLabel does not reserve (it covers the tick labels only).
2181
+ function axisGrid(position, titleSide) {
2182
+ const grid = legendGrid(position);
2183
+ switch (titleSide) {
2184
+ case 'bottom':
2185
+ return { ...grid, bottom: grid.bottom + AXIS_NAME_ROOM };
2186
+ case 'left':
2187
+ return { ...grid, left: grid.left + AXIS_NAME_ROOM };
2188
+ default:
2189
+ return grid;
2190
+ }
2191
+ }
2192
+ // Plot-edge room on whichever side the legend occupies; containLabel keeps the
2193
+ // tick labels inside what is left.
2194
+ function legendGrid(position) {
1530
2195
  const base = { left: 8, right: 16, top: 8, bottom: 8, containLabel: true };
1531
2196
  switch (position) {
1532
2197
  case 'bottom':
@@ -1769,15 +2434,33 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
1769
2434
  type: Input
1770
2435
  }] } });
1771
2436
 
1772
- // A list component: FsList + FsFilter rendered from the component's config and
1773
- // its report_filters rows. The component's own filter bar carries its filters
1774
- // whose group renders at the component level; report-level groups apply
1775
- // through the shared filter-state service and merge into every request.
2437
+ // A report row's date cell as the calendar day in the report's zone. Use it in
2438
+ // place of Angular's DatePipe, which reads a 'YYYY-MM-DD HH:MM:SS' value as the
2439
+ // browser's local wall time instead of the UTC instant it is.
2440
+ class ReportDatePipe {
2441
+ transform(value, timezone) {
2442
+ return formatReportDate(value, timezone);
2443
+ }
2444
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportDatePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
2445
+ static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "18.2.14", ngImport: i0, type: ReportDatePipe, isStandalone: true, name: "fsReportDate" });
2446
+ }
2447
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportDatePipe, decorators: [{
2448
+ type: Pipe,
2449
+ args: [{
2450
+ name: 'fsReportDate',
2451
+ standalone: true,
2452
+ }]
2453
+ }] });
2454
+
2455
+ // A list component: an FsList rendered from the component's config. It has no
2456
+ // filter bar of its own: its filters are group-keyed like every other
2457
+ // component's, so component-level groups render in the component header strip
2458
+ // (ReportComponentComponent) and report-level groups in the report bar, and
2459
+ // every request takes its values from the shared filter-state service.
1776
2460
  // Paging and sorting are pushed into the SQL wrap server-side.
1777
2461
  class ComponentListComponent {
1778
2462
  reportId;
1779
2463
  component;
1780
- groups;
1781
2464
  listComponent;
1782
2465
  listConfig;
1783
2466
  // A plain list's columns are authored and fixed at init. A cross-tab's are
@@ -1796,6 +2479,10 @@ class ComponentListComponent {
1796
2479
  // (it only renders what _load() fetched, which lists skip), so the warning
1797
2480
  // belongs here.
1798
2481
  truncated = signal(false);
2482
+ // The report's effective zone, which date cells render in. Injected right
2483
+ // here rather than read off _filterState, which is declared further down and
2484
+ // is still undefined while this initializer runs.
2485
+ timezone = inject(ReportFilterStateService).timezone;
1799
2486
  _reportData = inject(ReportData);
1800
2487
  _filterState = inject(ReportFilterStateService);
1801
2488
  _destroyRef = inject(DestroyRef);
@@ -1804,23 +2491,28 @@ class ComponentListComponent {
1804
2491
  this.pivoted.set(!!config.pivotBy?.column);
1805
2492
  this.columns.set(config.columns ?? []);
1806
2493
  this._initListConfig(config);
1807
- // Report-level filter changes that scope this component re-run the list.
2494
+ // A change to any group this list's filters belong to (from the report bar
2495
+ // or the component header strip) re-runs the list.
1808
2496
  this._filterState.changesFor(this.component)
1809
2497
  .pipe(takeUntilDestroyed(this._destroyRef))
1810
2498
  .subscribe(() => this.listComponent?.reload());
1811
2499
  }
1812
2500
  _initListConfig(config) {
2501
+ // The viewer's last sort wins over the authored one, so a list recreated
2502
+ // in the same session (page switch, reload after an edit) keeps it.
2503
+ const sort = this._filterState.listSort(this.component.id) ?? config.sort;
1813
2504
  this.listConfig = {
1814
2505
  // Inside a report component the list is a clean data pane — no status
1815
2506
  // bar, no reload button (filter changes already refetch).
1816
2507
  status: false,
1817
2508
  reload: false,
1818
- // Never touch the URL or persist filter/paging state — report filtering
1819
- // is session-only.
2509
+ // FsList never touches the URL or persists sort/paging state. The sort
2510
+ // reaches the URL through ReportFilterStateService (setListSort), and
2511
+ // paging stays out of it.
1820
2512
  queryParam: false,
1821
2513
  persist: false,
1822
- sort: config.sort
1823
- ? { value: config.sort.column, direction: config.sort.direction ?? 'asc' }
2514
+ sort: sort
2515
+ ? { value: sort.column, direction: sort.direction ?? 'asc' }
1824
2516
  : undefined,
1825
2517
  // Load-more: the list keeps a fixed, scrollable height and appends the
1826
2518
  // next page in place instead of swapping pages. The button is pinned to
@@ -1841,50 +2533,9 @@ class ComponentListComponent {
1841
2533
  label: 'Load more',
1842
2534
  buttonType: 'stroked',
1843
2535
  },
1844
- filters: this._ownFilters().map((filter) => this._filterItem(filter)),
1845
2536
  fetch: (query) => this._fetch(query),
1846
2537
  };
1847
2538
  }
1848
- // The filters this list renders in its own bar: its report_filters whose
1849
- // group is component-level. Report-level groups render in the report bar.
1850
- _ownFilters() {
1851
- return (this.component.filters ?? [])
1852
- .filter((filter) => {
1853
- const group = this.groups?.get(filter.filterGroupId);
1854
- return filter.enabled && (!group || group.level === 'component' || group.level === 'both');
1855
- });
1856
- }
1857
- _filterItem(filter) {
1858
- const group = this.groups?.get(filter.filterGroupId);
1859
- const name = `f${filter.id}`;
1860
- const label = filter.label || group?.label || filter.filterColumn;
1861
- switch (group?.type) {
1862
- case 'dateRange':
1863
- return {
1864
- name,
1865
- type: ItemType.DateRange,
1866
- label: [`${label} From`, `${label} To`],
1867
- };
1868
- case 'select':
1869
- return {
1870
- name,
1871
- type: ItemType.AutoCompleteChips,
1872
- label,
1873
- // The server sends { value, label }: the filter matches on the value
1874
- // (an entity's stable id) while the viewer reads the label.
1875
- values: () => this._reportData.filterOptions(this.reportId, filter.id)
1876
- .pipe(map$1((options) => (options ?? [])
1877
- .map((option) => ({ name: option.label, value: option.value })))),
1878
- };
1879
- case 'keyword':
1880
- default:
1881
- return {
1882
- name,
1883
- type: ItemType.Keyword,
1884
- label,
1885
- };
1886
- }
1887
- }
1888
2539
  _fetch(query) {
1889
2540
  // Many/load-more sends `offset` + `limit` (not `page`); derive the page so
1890
2541
  // the server-side wrap keeps working unchanged. Page strategy still sends
@@ -1901,17 +2552,12 @@ class ComponentListComponent {
1901
2552
  const [column, direction] = order.split(',');
1902
2553
  state.sort = { column, direction: direction === 'desc' ? 'desc' : 'asc' };
1903
2554
  }
1904
- // Report-level values from the shared session state, merged with this
1905
- // list's own filter-bar values (own values win per filter).
1906
- const resolved = new Map();
1907
- for (const value of this._filterState.resolveForComponent(this.component)) {
1908
- resolved.set(value.filterId, value);
1909
- }
1910
- for (const value of this._barValues(query)) {
1911
- resolved.set(value.filterId, value);
1912
- }
2555
+ this._filterState.setListSort(this.component.id, state.sort ?? null);
2556
+ // One source for filter values, the same one charts, KPIs and the exports
2557
+ // read, so the list is filtered exactly as its header strip shows.
2558
+ const filters = this._filterState.resolveForComponent(this.component);
1913
2559
  return this._reportData
1914
- .componentData(this.reportId, this.component.id, [...resolved.values()], state)
2560
+ .componentData(this.reportId, this.component.id, filters, state)
1915
2561
  .pipe(map$1((data) => {
1916
2562
  // A cross-tab's headers come back with the rows: the row-key column
1917
2563
  // first, then one per pivot value. Re-read them on every fetch, since
@@ -1959,54 +2605,18 @@ class ComponentListComponent {
1959
2605
  .map((match) => match[1]));
1960
2606
  return years.size > 1;
1961
2607
  }
1962
- // The list's own FsFilter values, read back out of the fetch query by the
1963
- // `f<filterId>` naming convention.
1964
- _barValues(query) {
1965
- const values = [];
1966
- for (const filter of this._ownFilters()) {
1967
- const group = this.groups?.get(filter.filterGroupId);
1968
- const name = `f${filter.id}`;
1969
- if (group?.type === 'dateRange') {
1970
- const from = dateBoundString(query[`${name}From`]);
1971
- const to = dateBoundString(query[`${name}To`]);
1972
- if (from || to) {
1973
- values.push({ filterId: filter.id, start: from, end: to });
1974
- }
1975
- }
1976
- else if (group?.type === 'select') {
1977
- // fs-filter joins a multi-select into one "a,b" string; split it back
1978
- // so each value binds separately (else `col IN ('a,b')` matches nothing).
1979
- const selected = query[name];
1980
- const selectedValues = Array.isArray(selected)
1981
- ? selected
1982
- : (selected ? String(selected).split(',') : []);
1983
- if (selectedValues.length) {
1984
- values.push({ filterId: filter.id, values: selectedValues });
1985
- }
1986
- }
1987
- else {
1988
- const keyword = String(query[name] ?? '').trim();
1989
- if (keyword) {
1990
- values.push({ filterId: filter.id, value: keyword });
1991
- }
1992
- }
1993
- }
1994
- return values;
1995
- }
1996
2608
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1997
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentListComponent, isStandalone: true, selector: "app-report-component-list", inputs: { reportId: "reportId", component: "component", groups: "groups" }, viewQueries: [{ propertyName: "listComponent", first: true, predicate: FsListComponent, descendants: true }], ngImport: i0, template: "@if (truncated()) {\n <div class=\"pivot-truncated\">\n This grid was built from a partial result, so some columns or values may be missing. Narrow it with a filter.\n </div>\n}\n\n<fs-list [config]=\"listConfig\">\n @for (column of columns(); track column.column) {\n <fs-list-column\n [name]=\"column.column\"\n [title]=\"column.label || column.column\"\n [sortable]=\"!pivoted() || $first\">\n <ng-template\n fs-list-cell\n let-row=\"row\">\n @if (column.format === 'date') {\n {{ row[column.column] | date: 'mediumDate' }}\n } @else {\n {{ row[column.column] }}\n }\n </ng-template>\n </fs-list-column>\n }\n</fs-list>\n", styles: [":host{display:block;width:100%;height:100%;min-height:0}:host ::ng-deep fs-list{display:flex;flex-direction:column;height:100%;min-height:0}:host ::ng-deep fs-list .fs-list-container{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;height:100%}:host ::ng-deep fs-list .fs-list-header-container{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-container .fs-list-table-container{flex:1 1 auto;min-height:0;overflow-y:auto!important}:host ::ng-deep fs-list .fs-list-table thead th{position:sticky;top:0;z-index:1;background-color:#fff}:host ::ng-deep fs-list fs-list-pagination{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-load-more{position:sticky;bottom:0;background-color:#fff}:host ::ng-deep fs-list .fs-list-load-more button{margin-top:0}.pivot-truncated{flex:0 0 auto;padding:8px 12px;margin-bottom:8px;border-radius:4px;background-color:#fff4e5;color:#663c00}\n"], dependencies: [{ kind: "pipe", type: DatePipe, name: "date" }, { kind: "ngmodule", type: FsListModule }, { kind: "component", type: i1.FsListComponent, selector: "fs-list", inputs: ["config", "loaderLines", "cellRowType"], outputs: ["filtersReady"] }, { kind: "directive", type: i1.FsListColumnDirective, selector: "fs-list-column", inputs: ["show", "title", "name", "customizable", "sortable", "sortableDefault", "sortableDirection", "direction", "align", "width", "class"] }, { kind: "directive", type: i1.FsListCellDirective, selector: "[fs-list-cell],[fsListCell]", inputs: ["colspan", "align", "class", "fsListCell", "configTyping"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2609
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentListComponent, isStandalone: true, selector: "app-report-component-list", inputs: { reportId: "reportId", component: "component" }, viewQueries: [{ propertyName: "listComponent", first: true, predicate: FsListComponent, descendants: true }], ngImport: i0, template: "@if (truncated()) {\n <div class=\"pivot-truncated\">\n This grid was built from a partial result, so some columns or values may be missing. Narrow it with a filter.\n </div>\n}\n\n<fs-list [config]=\"listConfig\">\n @for (column of columns(); track column.column) {\n <fs-list-column\n [name]=\"column.column\"\n [title]=\"column.label || column.column\"\n [sortable]=\"!pivoted() || $first\">\n <ng-template\n fs-list-cell\n let-row=\"row\">\n @if (column.format === 'date') {\n {{ row[column.column] | fsReportDate: timezone() }}\n } @else {\n {{ row[column.column] }}\n }\n </ng-template>\n </fs-list-column>\n }\n</fs-list>\n", styles: [":host{display:block;width:100%;height:100%;min-height:0}:host ::ng-deep fs-list{display:flex;flex-direction:column;height:100%;min-height:0}:host ::ng-deep fs-list .fs-list-container{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;height:100%}:host ::ng-deep fs-list .fs-list-header-container{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-container .fs-list-table-container{flex:1 1 auto;min-height:0;overflow-y:auto!important}:host ::ng-deep fs-list .fs-list-table thead th{position:sticky;top:0;z-index:1;background-color:#fff}:host ::ng-deep fs-list fs-list-pagination{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-load-more{position:sticky;bottom:0;background-color:#fff}:host ::ng-deep fs-list .fs-list-load-more button{margin-top:0}.pivot-truncated{flex:0 0 auto;padding:8px 12px;margin-bottom:8px;border-radius:4px;background-color:#fff4e5;color:#663c00}\n"], dependencies: [{ kind: "ngmodule", type: FsListModule }, { kind: "component", type: i1.FsListComponent, selector: "fs-list", inputs: ["config", "loaderLines", "cellRowType"], outputs: ["filtersReady"] }, { kind: "directive", type: i1.FsListColumnDirective, selector: "fs-list-column", inputs: ["show", "title", "name", "customizable", "sortable", "sortableDefault", "sortableDirection", "direction", "align", "width", "class"] }, { kind: "directive", type: i1.FsListCellDirective, selector: "[fs-list-cell],[fsListCell]", inputs: ["colspan", "align", "class", "fsListCell", "configTyping"] }, { kind: "pipe", type: ReportDatePipe, name: "fsReportDate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1998
2610
  }
1999
2611
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentListComponent, decorators: [{
2000
2612
  type: Component,
2001
2613
  args: [{ selector: 'app-report-component-list', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
2002
- DatePipe,
2003
2614
  FsListModule,
2004
- ], template: "@if (truncated()) {\n <div class=\"pivot-truncated\">\n This grid was built from a partial result, so some columns or values may be missing. Narrow it with a filter.\n </div>\n}\n\n<fs-list [config]=\"listConfig\">\n @for (column of columns(); track column.column) {\n <fs-list-column\n [name]=\"column.column\"\n [title]=\"column.label || column.column\"\n [sortable]=\"!pivoted() || $first\">\n <ng-template\n fs-list-cell\n let-row=\"row\">\n @if (column.format === 'date') {\n {{ row[column.column] | date: 'mediumDate' }}\n } @else {\n {{ row[column.column] }}\n }\n </ng-template>\n </fs-list-column>\n }\n</fs-list>\n", styles: [":host{display:block;width:100%;height:100%;min-height:0}:host ::ng-deep fs-list{display:flex;flex-direction:column;height:100%;min-height:0}:host ::ng-deep fs-list .fs-list-container{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;height:100%}:host ::ng-deep fs-list .fs-list-header-container{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-container .fs-list-table-container{flex:1 1 auto;min-height:0;overflow-y:auto!important}:host ::ng-deep fs-list .fs-list-table thead th{position:sticky;top:0;z-index:1;background-color:#fff}:host ::ng-deep fs-list fs-list-pagination{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-load-more{position:sticky;bottom:0;background-color:#fff}:host ::ng-deep fs-list .fs-list-load-more button{margin-top:0}.pivot-truncated{flex:0 0 auto;padding:8px 12px;margin-bottom:8px;border-radius:4px;background-color:#fff4e5;color:#663c00}\n"] }]
2615
+ ReportDatePipe,
2616
+ ], template: "@if (truncated()) {\n <div class=\"pivot-truncated\">\n This grid was built from a partial result, so some columns or values may be missing. Narrow it with a filter.\n </div>\n}\n\n<fs-list [config]=\"listConfig\">\n @for (column of columns(); track column.column) {\n <fs-list-column\n [name]=\"column.column\"\n [title]=\"column.label || column.column\"\n [sortable]=\"!pivoted() || $first\">\n <ng-template\n fs-list-cell\n let-row=\"row\">\n @if (column.format === 'date') {\n {{ row[column.column] | fsReportDate: timezone() }}\n } @else {\n {{ row[column.column] }}\n }\n </ng-template>\n </fs-list-column>\n }\n</fs-list>\n", styles: [":host{display:block;width:100%;height:100%;min-height:0}:host ::ng-deep fs-list{display:flex;flex-direction:column;height:100%;min-height:0}:host ::ng-deep fs-list .fs-list-container{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;height:100%}:host ::ng-deep fs-list .fs-list-header-container{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-container .fs-list-table-container{flex:1 1 auto;min-height:0;overflow-y:auto!important}:host ::ng-deep fs-list .fs-list-table thead th{position:sticky;top:0;z-index:1;background-color:#fff}:host ::ng-deep fs-list fs-list-pagination{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-load-more{position:sticky;bottom:0;background-color:#fff}:host ::ng-deep fs-list .fs-list-load-more button{margin-top:0}.pivot-truncated{flex:0 0 auto;padding:8px 12px;margin-bottom:8px;border-radius:4px;background-color:#fff4e5;color:#663c00}\n"] }]
2005
2617
  }], propDecorators: { reportId: [{
2006
2618
  type: Input
2007
- }], component: [{
2008
- type: Input
2009
- }], groups: [{
2619
+ }], component: [{
2010
2620
  type: Input
2011
2621
  }], listComponent: [{
2012
2622
  type: ViewChild,
@@ -2248,20 +2858,22 @@ class ReportComponentComponent {
2248
2858
  }
2249
2859
  // The component's header IS an fs-filter: heading template = its title (and
2250
2860
  // the drag handle), actions = the Settings / filter-toggles / Export menu,
2251
- // items = its component-level filter groups (none for lists — they filter
2252
- // through FsList). Rebuilt when a filter toggle changes which groups show.
2253
- filterConfig;
2254
- data = null;
2255
- loading = false;
2256
- error = null;
2861
+ // items = its component-level filter groups, for every component type, lists
2862
+ // included. Rebuilt when a filter toggle changes which groups show,
2863
+ // and when the settings dialog changes the title or padding: setting it
2864
+ // repaints the header and body.
2865
+ filterConfig = signal(null);
2866
+ data = signal(null);
2867
+ loading = signal(false);
2868
+ error = signal(null);
2257
2869
  _refresh$ = new Subject();
2258
2870
  _reportData = inject(ReportData);
2259
2871
  _filterState = inject(ReportFilterStateService);
2260
2872
  _process = inject(FsProcess);
2873
+ _message = inject(FsMessage);
2261
2874
  _prompt = inject(FsPrompt);
2262
2875
  _lastPress = 0;
2263
2876
  _destroyRef = inject(DestroyRef);
2264
- _cdRef = inject(ChangeDetectorRef);
2265
2877
  _zone = inject(NgZone);
2266
2878
  _host = inject(ElementRef);
2267
2879
  ngOnInit() {
@@ -2342,7 +2954,7 @@ class ReportComponentComponent {
2342
2954
  .subscribe(() => this.removed.emit(this.component.id));
2343
2955
  }
2344
2956
  get truncated() {
2345
- return !!this.data?.truncated;
2957
+ return !!this.data()?.truncated;
2346
2958
  }
2347
2959
  // Content padding (inches → px at 96/in; the canvas zoom scales the page as
2348
2960
  // a whole). Each edge defaults to 1/8".
@@ -2354,10 +2966,10 @@ class ReportComponentComponent {
2354
2966
  // The component-level filter groups that should render as an inline control
2355
2967
  // strip on THIS component: a group is included when it's level=component and
2356
2968
  // has an enabled, session-on filter here. Report-level groups live in the
2357
- // top bar instead; lists render their own filters through FsList, so they
2358
- // never use this strip.
2969
+ // top bar instead. Lists use this strip like charts do: a value set here is
2970
+ // the group's value, so it reaches every component in the group.
2359
2971
  get componentFilterGroups() {
2360
- if (this.component.type === 'list' || !this.groups) {
2972
+ if (!this.groups) {
2361
2973
  return [];
2362
2974
  }
2363
2975
  const groupIds = new Set();
@@ -2378,17 +2990,36 @@ class ReportComponentComponent {
2378
2990
  // Presentation settings saved from the settings dialog. Title and padding are
2379
2991
  // not part of the query, so the data already fetched still stands — this only
2380
2992
  // repaints. The header config is rebuilt because `filterKey` changes with the
2381
- // title and recreates the fs-filter block around it. The chart follows its box
2382
- // on its own (ngx-echarts observes the host), so nothing is told to resize.
2993
+ // title and recreates the fs-filter block around it; setting the config signal
2994
+ // is also what repaints the new padding. The chart follows its box on its own
2995
+ // (ngx-echarts observes the host), so nothing is told to resize.
2383
2996
  applySettings(settings) {
2384
2997
  this.component.title = settings.title;
2385
2998
  this.component.config = { ...this.component.config, padding: settings.padding };
2386
2999
  this._buildFilterConfig();
2387
- this._cdRef.markForCheck();
2388
3000
  }
3001
+ // The CSV is what the screen shows, so it carries the same interaction state
3002
+ // the screen fetched with: the group-by grain for a time-series chart and the
3003
+ // viewer's header sort for a list. A note the server returns (the file was
3004
+ // cut) is shown before the download starts, so a short file never passes for
3005
+ // the whole result.
2389
3006
  exportCsv() {
2390
3007
  const filters = this._filterState.resolveForComponent(this.component);
2391
- this._process.download(`Exporting ${this.component.title ?? 'component'}`, this._reportData.exportComponent(this.reportId, this.component.id, filters));
3008
+ const state = {};
3009
+ // A deliberate mirror of the server's ReportQueryRunner::_isTimeSeriesChart,
3010
+ // which is canonical and drops the grain for every other component. Kept so
3011
+ // a product pinned to an older framework does not re-bucket a non-time chart.
3012
+ if (this._isTimeSeries && this._filterState.frequency()) {
3013
+ state.frequency = this._filterState.frequency();
3014
+ }
3015
+ const sort = this.component.type === 'list'
3016
+ ? this._filterState.listSort(this.component.id)
3017
+ : null;
3018
+ if (sort) {
3019
+ state.sort = sort;
3020
+ }
3021
+ this._process.download(`Exporting ${this.component.title ?? 'component'}`, this._reportData.exportComponent(this.reportId, this.component.id, filters, state)
3022
+ .pipe(tap(({ notes }) => notes?.length && this._message.warning(notes.join(' '))), map$1(({ url }) => url)));
2392
3023
  }
2393
3024
  // Any interaction inside a component must never start a canvas pan (the
2394
3025
  // zoom-pan host listens for mousedown/touchstart at the container level).
@@ -2478,9 +3109,10 @@ class ReportComponentComponent {
2478
3109
  // top-right in the template, so its button height no longer drives the header
2479
3110
  // row's height (which would otherwise inflate the heading by ~40px).
2480
3111
  _buildFilterConfig() {
2481
- this.filterConfig = {
2482
- // Never touch the URL or persist filter state — component filters are
2483
- // session-only, owned by ReportFilterStateService.
3112
+ this.filterConfig.set({
3113
+ // fs-filter never touches the URL or persists filter state. The values
3114
+ // are owned by ReportFilterStateService, which feeds the report view's
3115
+ // one URL writer (report-url-state.ts).
2484
3116
  queryParam: false,
2485
3117
  persist: false,
2486
3118
  items: this.componentFilterGroups.map((group) => filterItemForGroup(group, this._reportData, this.reportId, this._filterState.value(group.id))),
@@ -2489,7 +3121,7 @@ class ReportComponentComponent {
2489
3121
  this._filterState.setValue(groupId, value);
2490
3122
  }
2491
3123
  },
2492
- };
3124
+ });
2493
3125
  }
2494
3126
  // Flow: only the east handle resizes (width %). Height is always auto in flow
2495
3127
  // (the box fits its content), so there's no south handle here.
@@ -2557,7 +3189,6 @@ class ReportComponentComponent {
2557
3189
  this._zone.run(() => {
2558
3190
  this.snapper?.clear();
2559
3191
  onEnd();
2560
- this._cdRef.markForCheck();
2561
3192
  });
2562
3193
  };
2563
3194
  handle.addEventListener('pointermove', move);
@@ -2581,7 +3212,8 @@ class ReportComponentComponent {
2581
3212
  });
2582
3213
  }
2583
3214
  // A chart on a time x-axis — the only component the report's group-by control
2584
- // applies to.
3215
+ // applies to. The server owns this rule (ReportQueryRunner::_isTimeSeriesChart);
3216
+ // this is its documented browser-side mirror, for products on an older framework.
2585
3217
  get _isTimeSeries() {
2586
3218
  return this.component.type === 'chart' && this.component.config?.xAxis?.kind === 'time';
2587
3219
  }
@@ -2631,9 +3263,8 @@ class ReportComponentComponent {
2631
3263
  }
2632
3264
  // ----- data ----------------------------------------------------------------
2633
3265
  _load() {
2634
- this.loading = true;
2635
- this.error = null;
2636
- this._cdRef.markForCheck();
3266
+ this.loading.set(true);
3267
+ this.error.set(null);
2637
3268
  const filters = this._filterState.resolveForComponent(this.component);
2638
3269
  // Time-series charts carry the group-by grain (if the viewer picked one) so
2639
3270
  // the backend re-buckets; everything else sends no interaction state here.
@@ -2644,19 +3275,17 @@ class ReportComponentComponent {
2644
3275
  .pipe(takeUntilDestroyed(this._destroyRef))
2645
3276
  .subscribe({
2646
3277
  next: (data) => {
2647
- this.data = data;
2648
- this.loading = false;
2649
- this._cdRef.markForCheck();
3278
+ this.data.set(data);
3279
+ this.loading.set(false);
2650
3280
  },
2651
3281
  error: (error) => {
2652
- this.loading = false;
2653
- this.error = error?.error?.message ?? error?.message ?? 'Failed to load data';
2654
- this._cdRef.markForCheck();
3282
+ this.loading.set(false);
3283
+ this.error.set(error?.error?.message ?? error?.message ?? 'Failed to load data');
2655
3284
  },
2656
3285
  });
2657
3286
  }
2658
3287
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2659
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportComponentComponent, isStandalone: true, selector: "app-report-component", inputs: { reportId: { classPropertyName: "reportId", publicName: "reportId", isSignal: false, isRequired: false, transformFunction: null }, component: { classPropertyName: "component", publicName: "component", isSignal: false, isRequired: false, transformFunction: null }, groups: { classPropertyName: "groups", publicName: "groups", isSignal: false, isRequired: false, transformFunction: null }, editMode: { classPropertyName: "editMode", publicName: "editMode", isSignal: false, isRequired: false, transformFunction: null }, layout: { classPropertyName: "layout", publicName: "layout", isSignal: false, isRequired: false, transformFunction: null }, zoom: { classPropertyName: "zoom", publicName: "zoom", isSignal: false, isRequired: false, transformFunction: null }, snapper: { classPropertyName: "snapper", publicName: "snapper", isSignal: false, isRequired: false, transformFunction: null }, selected: { classPropertyName: "selected", publicName: "selected", isSignal: false, isRequired: false, transformFunction: null }, tokens: { classPropertyName: "tokens", publicName: "tokens", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectComponent: "selectComponent", positionChanged: "positionChanged", flowReorder: "flowReorder", openSettings: "openSettings", removed: "removed" }, 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.text]=\"isText\"\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 <!-- A text block has no title bar \u2014 it IS its words, like a text box on a\n slide \u2014 so it gets neither the header nor the data actions. -->\n @for (key of isText ? [] : [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 @if (!isText || (editMode && !textEditing())) {\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 }\n <mat-menu #menu=\"matMenu\">\n @if (isText) {\n <button mat-menu-item (click)=\"editText()\">\n <mat-icon>edit</mat-icon>\n <span>Edit text</span>\n </button>\n <button mat-menu-item (click)=\"deleteText()\">\n <mat-icon>delete</mat-icon>\n <span>Delete</span>\n </button>\n } @else {\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 }\n </mat-menu>\n <div class=\"component-body\">\n @if (isText) {\n <app-report-component-text\n [html]=\"component.config?.html ?? ''\"\n [tokens]=\"tokens()\"\n [editing]=\"textEditing()\"\n (saved)=\"onTextSaved($event)\"\n (editEnded)=\"textEditing.set(false)\">\n </app-report-component-text>\n } @else 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 && !textEditing()) {\n <div\n class=\"drag-veil\"\n (pointerdown)=\"onDragStart($event)\">\n </div>\n }\n </div>\n</div>\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\n @if (layout === 'freeform') {\n @for (handle of resizeHandles; track handle) {\n <div\n class=\"handle handle-{{ handle }}\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, handle)\">\n </div>\n }\n } @else {\n <!-- Flow height is always auto (the box fits its content), so only the\n east handle (width %) is offered here. -->\n <div\n class=\"handle handle-e\"\n matTooltip=\"Width (% of page)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, 'e')\">\n </div>\n }\n}", styles: [":host{position:absolute;display:flex;flex-direction:column;font-size:10px;--mdc-outlined-button-container-height: 30px;--mdc-outlined-button-label-text-size: 10px;--mat-outlined-button-horizontal-padding: 15px;--mdc-text-button-container-height: 30px;--mdc-text-button-label-text-size: 10px;--mat-text-button-horizontal-padding: 15px;--mat-button-outlined-container-height: 30px;--mat-button-outlined-label-text-size: 10px;--mat-button-outlined-horizontal-padding: 15px;--mat-button-text-container-height: 30px;--mat-button-text-label-text-size: 10px;--mat-button-text-horizontal-padding: 15px}:host.flow-item{position:relative;left:auto;top:auto}:host.flow-dragging{z-index:40;opacity:.7;pointer-events:none}.component-box{position:relative;width:100%;flex:1 1 auto;min-height:0;box-sizing:border-box;display:flex;flex-direction:column;background:#fff;border:1px solid #e4e7eb;border-radius:6px;overflow:hidden;transition:box-shadow .12s ease,border-color .12s ease;cursor:default}.component-box.editing .component-title{cursor:grab}.component-box.editing .component-title:active{cursor:grabbing}.component-box.editing:hover{border-color:#9db3c8;box-shadow:0 2px 8px #0f172a14}.component-box.selected{border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color),0 4px 14px #2196f32e}.component-box.text{background:transparent;border-color:transparent;border-radius:0;overflow:visible}.component-box.text.editing{border-style:dashed;border-color:#cbd2d9}.component-box.text.editing:hover{box-shadow:none}.component-box.text.selected{border-style:solid;border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color)}: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$2.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$2.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i1$2.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"] }, { kind: "component", type: ComponentTextComponent, selector: "app-report-component-text", inputs: ["html", "tokens", "editing"], outputs: ["saved", "editEnded"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3288
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportComponentComponent, isStandalone: true, selector: "app-report-component", inputs: { reportId: { classPropertyName: "reportId", publicName: "reportId", isSignal: false, isRequired: false, transformFunction: null }, component: { classPropertyName: "component", publicName: "component", isSignal: false, isRequired: false, transformFunction: null }, groups: { classPropertyName: "groups", publicName: "groups", isSignal: false, isRequired: false, transformFunction: null }, editMode: { classPropertyName: "editMode", publicName: "editMode", isSignal: false, isRequired: false, transformFunction: null }, layout: { classPropertyName: "layout", publicName: "layout", isSignal: false, isRequired: false, transformFunction: null }, zoom: { classPropertyName: "zoom", publicName: "zoom", isSignal: false, isRequired: false, transformFunction: null }, snapper: { classPropertyName: "snapper", publicName: "snapper", isSignal: false, isRequired: false, transformFunction: null }, selected: { classPropertyName: "selected", publicName: "selected", isSignal: false, isRequired: false, transformFunction: null }, tokens: { classPropertyName: "tokens", publicName: "tokens", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectComponent: "selectComponent", positionChanged: "positionChanged", flowReorder: "flowReorder", openSettings: "openSettings", removed: "removed" }, 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.text]=\"isText\"\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 filter groups,\n the same strip for every type (lists included). fs-filter reads its\n config once, so the block is keyed on the component-filter set to be\n recreated when it changes. -->\n <!-- A text block has no title bar \u2014 it IS its words, like a text box on a\n slide \u2014 so it gets neither the header nor the data actions. -->\n @for (key of isText ? [] : [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 @if (!isText || (editMode && !textEditing())) {\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 }\n <mat-menu #menu=\"matMenu\">\n @if (isText) {\n <button mat-menu-item (click)=\"editText()\">\n <mat-icon>edit</mat-icon>\n <span>Edit text</span>\n </button>\n <button mat-menu-item (click)=\"deleteText()\">\n <mat-icon>delete</mat-icon>\n <span>Delete</span>\n </button>\n } @else {\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 }\n </mat-menu>\n <div class=\"component-body\">\n @if (isText) {\n <app-report-component-text\n [html]=\"component.config?.html ?? ''\"\n [tokens]=\"tokens()\"\n [editing]=\"textEditing()\"\n (saved)=\"onTextSaved($event)\"\n (editEnded)=\"textEditing.set(false)\">\n </app-report-component-text>\n } @else if (component.type === 'list') {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\">\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 && !textEditing()) {\n <div\n class=\"drag-veil\"\n (pointerdown)=\"onDragStart($event)\">\n </div>\n }\n </div>\n</div>\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\n @if (layout === 'freeform') {\n @for (handle of resizeHandles; track handle) {\n <div\n class=\"handle handle-{{ handle }}\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, handle)\">\n </div>\n }\n } @else {\n <!-- Flow height is always auto (the box fits its content), so only the\n east handle (width %) is offered here. -->\n <div\n class=\"handle handle-e\"\n matTooltip=\"Width (% of page)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, 'e')\">\n </div>\n }\n}", styles: [":host{position:absolute;display:flex;flex-direction:column;font-size:10px;--mdc-outlined-button-container-height: 30px;--mdc-outlined-button-label-text-size: 10px;--mat-outlined-button-horizontal-padding: 15px;--mdc-text-button-container-height: 30px;--mdc-text-button-label-text-size: 10px;--mat-text-button-horizontal-padding: 15px;--mat-button-outlined-container-height: 30px;--mat-button-outlined-label-text-size: 10px;--mat-button-outlined-horizontal-padding: 15px;--mat-button-text-container-height: 30px;--mat-button-text-label-text-size: 10px;--mat-button-text-horizontal-padding: 15px}:host.flow-item{position:relative;left:auto;top:auto}:host.flow-dragging{z-index:40;opacity:.7;pointer-events:none}.component-box{position:relative;width:100%;flex:1 1 auto;min-height:0;box-sizing:border-box;display:flex;flex-direction:column;background:#fff;border:1px solid #e4e7eb;border-radius:6px;overflow:hidden;transition:box-shadow .12s ease,border-color .12s ease;cursor:default}.component-box.editing .component-title{cursor:grab}.component-box.editing .component-title:active{cursor:grabbing}.component-box.editing:hover{border-color:#9db3c8;box-shadow:0 2px 8px #0f172a14}.component-box.selected{border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color),0 4px 14px #2196f32e}.component-box.text{background:transparent;border-color:transparent;border-radius:0;overflow:visible}.component-box.text.editing{border-style:dashed;border-color:#cbd2d9}.component-box.text.editing:hover{box-shadow:none}.component-box.text.selected{border-style:solid;border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color)}: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$2.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$2.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i1$2.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"] }, { kind: "component", type: ComponentTextComponent, selector: "app-report-component-text", inputs: ["html", "tokens", "editing"], outputs: ["saved", "editEnded"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2660
3289
  }
2661
3290
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, decorators: [{
2662
3291
  type: Component,
@@ -2670,7 +3299,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
2670
3299
  ComponentKpiComponent,
2671
3300
  ComponentListComponent,
2672
3301
  ComponentTextComponent,
2673
- ], template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.text]=\"isText\"\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 <!-- A text block has no title bar \u2014 it IS its words, like a text box on a\n slide \u2014 so it gets neither the header nor the data actions. -->\n @for (key of isText ? [] : [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 @if (!isText || (editMode && !textEditing())) {\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 }\n <mat-menu #menu=\"matMenu\">\n @if (isText) {\n <button mat-menu-item (click)=\"editText()\">\n <mat-icon>edit</mat-icon>\n <span>Edit text</span>\n </button>\n <button mat-menu-item (click)=\"deleteText()\">\n <mat-icon>delete</mat-icon>\n <span>Delete</span>\n </button>\n } @else {\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 }\n </mat-menu>\n <div class=\"component-body\">\n @if (isText) {\n <app-report-component-text\n [html]=\"component.config?.html ?? ''\"\n [tokens]=\"tokens()\"\n [editing]=\"textEditing()\"\n (saved)=\"onTextSaved($event)\"\n (editEnded)=\"textEditing.set(false)\">\n </app-report-component-text>\n } @else 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 && !textEditing()) {\n <div\n class=\"drag-veil\"\n (pointerdown)=\"onDragStart($event)\">\n </div>\n }\n </div>\n</div>\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\n @if (layout === 'freeform') {\n @for (handle of resizeHandles; track handle) {\n <div\n class=\"handle handle-{{ handle }}\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, handle)\">\n </div>\n }\n } @else {\n <!-- Flow height is always auto (the box fits its content), so only the\n east handle (width %) is offered here. -->\n <div\n class=\"handle handle-e\"\n matTooltip=\"Width (% of page)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, 'e')\">\n </div>\n }\n}", styles: [":host{position:absolute;display:flex;flex-direction:column;font-size:10px;--mdc-outlined-button-container-height: 30px;--mdc-outlined-button-label-text-size: 10px;--mat-outlined-button-horizontal-padding: 15px;--mdc-text-button-container-height: 30px;--mdc-text-button-label-text-size: 10px;--mat-text-button-horizontal-padding: 15px;--mat-button-outlined-container-height: 30px;--mat-button-outlined-label-text-size: 10px;--mat-button-outlined-horizontal-padding: 15px;--mat-button-text-container-height: 30px;--mat-button-text-label-text-size: 10px;--mat-button-text-horizontal-padding: 15px}:host.flow-item{position:relative;left:auto;top:auto}:host.flow-dragging{z-index:40;opacity:.7;pointer-events:none}.component-box{position:relative;width:100%;flex:1 1 auto;min-height:0;box-sizing:border-box;display:flex;flex-direction:column;background:#fff;border:1px solid #e4e7eb;border-radius:6px;overflow:hidden;transition:box-shadow .12s ease,border-color .12s ease;cursor:default}.component-box.editing .component-title{cursor:grab}.component-box.editing .component-title:active{cursor:grabbing}.component-box.editing:hover{border-color:#9db3c8;box-shadow:0 2px 8px #0f172a14}.component-box.selected{border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color),0 4px 14px #2196f32e}.component-box.text{background:transparent;border-color:transparent;border-radius:0;overflow:visible}.component-box.text.editing{border-style:dashed;border-color:#cbd2d9}.component-box.text.editing:hover{box-shadow:none}.component-box.text.selected{border-style:solid;border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color)}: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"] }]
3302
+ ], template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.text]=\"isText\"\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 filter groups,\n the same strip for every type (lists included). fs-filter reads its\n config once, so the block is keyed on the component-filter set to be\n recreated when it changes. -->\n <!-- A text block has no title bar \u2014 it IS its words, like a text box on a\n slide \u2014 so it gets neither the header nor the data actions. -->\n @for (key of isText ? [] : [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 @if (!isText || (editMode && !textEditing())) {\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 }\n <mat-menu #menu=\"matMenu\">\n @if (isText) {\n <button mat-menu-item (click)=\"editText()\">\n <mat-icon>edit</mat-icon>\n <span>Edit text</span>\n </button>\n <button mat-menu-item (click)=\"deleteText()\">\n <mat-icon>delete</mat-icon>\n <span>Delete</span>\n </button>\n } @else {\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 }\n </mat-menu>\n <div class=\"component-body\">\n @if (isText) {\n <app-report-component-text\n [html]=\"component.config?.html ?? ''\"\n [tokens]=\"tokens()\"\n [editing]=\"textEditing()\"\n (saved)=\"onTextSaved($event)\"\n (editEnded)=\"textEditing.set(false)\">\n </app-report-component-text>\n } @else if (component.type === 'list') {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\">\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 && !textEditing()) {\n <div\n class=\"drag-veil\"\n (pointerdown)=\"onDragStart($event)\">\n </div>\n }\n </div>\n</div>\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\n @if (layout === 'freeform') {\n @for (handle of resizeHandles; track handle) {\n <div\n class=\"handle handle-{{ handle }}\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, handle)\">\n </div>\n }\n } @else {\n <!-- Flow height is always auto (the box fits its content), so only the\n east handle (width %) is offered here. -->\n <div\n class=\"handle handle-e\"\n matTooltip=\"Width (% of page)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, 'e')\">\n </div>\n }\n}", styles: [":host{position:absolute;display:flex;flex-direction:column;font-size:10px;--mdc-outlined-button-container-height: 30px;--mdc-outlined-button-label-text-size: 10px;--mat-outlined-button-horizontal-padding: 15px;--mdc-text-button-container-height: 30px;--mdc-text-button-label-text-size: 10px;--mat-text-button-horizontal-padding: 15px;--mat-button-outlined-container-height: 30px;--mat-button-outlined-label-text-size: 10px;--mat-button-outlined-horizontal-padding: 15px;--mat-button-text-container-height: 30px;--mat-button-text-label-text-size: 10px;--mat-button-text-horizontal-padding: 15px}:host.flow-item{position:relative;left:auto;top:auto}:host.flow-dragging{z-index:40;opacity:.7;pointer-events:none}.component-box{position:relative;width:100%;flex:1 1 auto;min-height:0;box-sizing:border-box;display:flex;flex-direction:column;background:#fff;border:1px solid #e4e7eb;border-radius:6px;overflow:hidden;transition:box-shadow .12s ease,border-color .12s ease;cursor:default}.component-box.editing .component-title{cursor:grab}.component-box.editing .component-title:active{cursor:grabbing}.component-box.editing:hover{border-color:#9db3c8;box-shadow:0 2px 8px #0f172a14}.component-box.selected{border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color),0 4px 14px #2196f32e}.component-box.text{background:transparent;border-color:transparent;border-radius:0;overflow:visible}.component-box.text.editing{border-style:dashed;border-color:#cbd2d9}.component-box.text.editing:hover{box-shadow:none}.component-box.text.selected{border-style:solid;border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color)}: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"] }]
2674
3303
  }], propDecorators: { reportId: [{
2675
3304
  type: Input
2676
3305
  }], component: [{
@@ -2709,7 +3338,8 @@ const PX_PER_INCH = 96;
2709
3338
  // The fixed-size page canvas — the PowerPoint model. Pages are tabs (only the
2710
3339
  // ACTIVE page's components exist → only they fetch); the page is a
2711
3340
  // pageWidth×pageHeight INCHES sheet rendered at 96px/in inside fs-zoom-pan
2712
- // (wheel zoom, background drag pan, toolbar buttons).
3341
+ // (Ctrl/Cmd + wheel or pinch to zoom, background drag pan, toolbar buttons; a
3342
+ // plain wheel scrolls the list under the pointer).
2713
3343
  //
2714
3344
  // Freeform layout: components are absolutely positioned; dragging gets smart
2715
3345
  // alignment guides (edges + centers of siblings and the page) with snapping.
@@ -2746,12 +3376,18 @@ class ReportCanvasComponent {
2746
3376
  _guides;
2747
3377
  PX = PX_PER_INCH;
2748
3378
  flowPadding = FLOW_PADDING;
2749
- activePageIndex = 0;
2750
- zoom = 1;
3379
+ // The report's pages as the canvas draws them. This is the host's own
3380
+ // `report.pages` array, patched in place (a rename, a delete, a reorder) so
3381
+ // the host's exports and page links see the change too. Because the array
3382
+ // and its pages keep their identity across a patch, the signal notifies on
3383
+ // every set rather than only when the reference changes.
3384
+ pages = signal([], { equal: () => false });
3385
+ activePageIndex = signal(0);
3386
+ zoom = signal(1);
2751
3387
  // Editing the header and footer (the master layer) rather than the page.
2752
3388
  masterMode = signal(false);
2753
3389
  groups = new Map();
2754
- selectedComponentId = null;
3390
+ selectedComponentId = signal(null);
2755
3391
  // The snapper handed to every component (stable identity for OnPush).
2756
3392
  snapper = {
2757
3393
  snapMove: (component, x, y) => this._snapMove(component, x, y),
@@ -2761,7 +3397,6 @@ class ReportCanvasComponent {
2761
3397
  _reportData = inject(ReportData);
2762
3398
  _prompt = inject(FsPrompt);
2763
3399
  _message = inject(FsMessage);
2764
- _cdRef = inject(ChangeDetectorRef);
2765
3400
  _destroyRef = inject(DestroyRef);
2766
3401
  _fitted = false;
2767
3402
  _tokens = null;
@@ -2777,11 +3412,12 @@ class ReportCanvasComponent {
2777
3412
  this.masterMode.set(false);
2778
3413
  }
2779
3414
  this.groups = new Map((this.report?.filterGroups ?? []).map((group) => [group.id, group]));
2780
- const pageCount = this.report?.pages?.length ?? 0;
3415
+ this.pages.set(this.report?.pages ?? []);
3416
+ const pageCount = this.pages().length;
2781
3417
  // A page was just added (the host re-fetched the report) — land on it.
2782
3418
  const pageAdded = pageCount > this._previousPageCount && this._previousPageCount > 0;
2783
3419
  if (pageAdded) {
2784
- this.activePageIndex = pageCount - 1;
3420
+ this.activePageIndex.set(pageCount - 1);
2785
3421
  }
2786
3422
  this._previousPageCount = pageCount;
2787
3423
  // Honour an incoming deep link, but never over a just-added page: adding a
@@ -2790,13 +3426,13 @@ class ReportCanvasComponent {
2790
3426
  // alone rather than forced to the first page — the report may still be
2791
3427
  // loading, and the clamp below is the real guard.
2792
3428
  if (!pageAdded && this.pageId != null) {
2793
- const index = this.report?.pages?.findIndex((page) => page.id === this.pageId) ?? -1;
3429
+ const index = this.pages().findIndex((page) => page.id === this.pageId);
2794
3430
  if (index > -1) {
2795
- this.activePageIndex = index;
3431
+ this.activePageIndex.set(index);
2796
3432
  }
2797
3433
  }
2798
- if (this.activePageIndex >= pageCount) {
2799
- this.activePageIndex = 0;
3434
+ if (this.activePageIndex() >= pageCount) {
3435
+ this.activePageIndex.set(0);
2800
3436
  }
2801
3437
  // Tell the host which page is open whenever that changes — including the
2802
3438
  // FIRST time the report renders, not only when someone clicks a page.
@@ -2824,7 +3460,7 @@ class ReportCanvasComponent {
2824
3460
  return this.report?.layout === 'flow';
2825
3461
  }
2826
3462
  get activePage() {
2827
- return this.report?.pages?.[this.activePageIndex] ?? null;
3463
+ return this.pages()[this.activePageIndex()] ?? null;
2828
3464
  }
2829
3465
  // The header/footer bands, in inches (0 = no band).
2830
3466
  get bands() {
@@ -2852,8 +3488,8 @@ class ReportCanvasComponent {
2852
3488
  // of them repaint its html on every change-detection pass.
2853
3489
  get tokens() {
2854
3490
  const page = this.activePage;
2855
- const pageNumber = this.activePageIndex + 1;
2856
- const pageName = page ? this.pageLabel(page, this.activePageIndex) : '';
3491
+ const pageNumber = this.activePageIndex() + 1;
3492
+ const pageName = page ? this.pageLabel(page, this.activePageIndex()) : '';
2857
3493
  const reportName = this.report?.name ?? '';
2858
3494
  const tokens = this._tokens;
2859
3495
  if (tokens?.pageNumber !== pageNumber || tokens.pageName !== pageName || tokens.reportName !== reportName) {
@@ -2863,7 +3499,7 @@ class ReportCanvasComponent {
2863
3499
  }
2864
3500
  toggleMasterMode() {
2865
3501
  this.masterMode.update((value) => !value);
2866
- this.selectedComponentId = null;
3502
+ this.selectedComponentId.set(null);
2867
3503
  }
2868
3504
  // Drop a text block where it will be found: into the footer (or header) band
2869
3505
  // while editing the header and footer, below the header band on a page. The
@@ -2901,9 +3537,9 @@ class ReportCanvasComponent {
2901
3537
  components.splice(index, 1);
2902
3538
  }
2903
3539
  }
2904
- this.selectedComponentId = null;
3540
+ this.selectedComponentId.set(null);
3541
+ this.pages.set(this.pages());
2905
3542
  this._message.success('Deleted text');
2906
- this._cdRef.markForCheck();
2907
3543
  }
2908
3544
  // Show or hide the header and footer on one page, from its tab's kebab.
2909
3545
  toggleMasterHidden(page) {
@@ -2912,8 +3548,8 @@ class ReportCanvasComponent {
2912
3548
  .pipe(takeUntilDestroyed(this._destroyRef))
2913
3549
  .subscribe(() => {
2914
3550
  page.masterHidden = masterHidden;
3551
+ this.pages.set(this.pages());
2915
3552
  this._message.success(masterHidden ? 'Header & footer hidden on this page' : 'Header & footer shown on this page');
2916
- this._cdRef.markForCheck();
2917
3553
  });
2918
3554
  }
2919
3555
  // Flow render order (the assembler already sorts, but a local reorder
@@ -2922,8 +3558,8 @@ class ReportCanvasComponent {
2922
3558
  return [...(this.activePage?.components ?? [])].sort((a, b) => a.order - b.order);
2923
3559
  }
2924
3560
  selectPage(index) {
2925
- this.activePageIndex = index;
2926
- this.selectedComponentId = null;
3561
+ this.activePageIndex.set(index);
3562
+ this.selectedComponentId.set(null);
2927
3563
  // Kept in step with the ngOnChanges announcement so the two can't disagree
2928
3564
  // about what the host was last told.
2929
3565
  this._emittedPageId = this.activePage?.id ?? null;
@@ -2959,8 +3595,8 @@ class ReportCanvasComponent {
2959
3595
  .pipe(map$1(() => name))), takeUntilDestroyed(this._destroyRef))
2960
3596
  .subscribe((name) => {
2961
3597
  page.name = name;
3598
+ this.pages.set(this.pages());
2962
3599
  this._message.success('Saved page name');
2963
- this._cdRef.markForCheck();
2964
3600
  });
2965
3601
  }
2966
3602
  // Delete from the tab's kebab. The confirm names the page and says the
@@ -2969,22 +3605,23 @@ class ReportCanvasComponent {
2969
3605
  deletePage(page) {
2970
3606
  this._prompt.confirm({
2971
3607
  title: 'Confirm',
2972
- template: `Are you sure you would like to delete "${this.pageLabel(page, this.report.pages.indexOf(page))}" and everything on it?`,
3608
+ template: `Are you sure you would like to delete "${this.pageLabel(page, this.pages().indexOf(page))}" and everything on it?`,
2973
3609
  })
2974
3610
  .pipe(switchMap(() => this._reportData.deletePage(this.report.id, page.id)), takeUntilDestroyed(this._destroyRef))
2975
3611
  .subscribe(() => {
2976
- const index = this.report.pages.indexOf(page);
2977
- this.report.pages.splice(index, 1);
3612
+ const pages = this.pages();
3613
+ const index = pages.indexOf(page);
3614
+ pages.splice(index, 1);
3615
+ this.pages.set(pages);
2978
3616
  // Land on the neighbour rather than snapping back to the first page —
2979
3617
  // deleting page 4 of 6 should leave you looking at what is now page 4.
2980
- this.activePageIndex = Math.min(index, this.report.pages.length - 1);
2981
- this._previousPageCount = this.report.pages.length;
2982
- this.selectedComponentId = null;
3618
+ this.activePageIndex.set(Math.min(index, pages.length - 1));
3619
+ this._previousPageCount = pages.length;
3620
+ this.selectedComponentId.set(null);
2983
3621
  // The id in the URL just stopped existing — replace it with the
2984
3622
  // neighbour we landed on, or a refresh would 404 its way to page one.
2985
3623
  this.pageChanged.emit(this.activePage?.id ?? null);
2986
3624
  this._message.success('Deleted page');
2987
- this._cdRef.markForCheck();
2988
3625
  });
2989
3626
  }
2990
3627
  // A tab was dragged to a new slot: reorder locally so the chips settle
@@ -2995,14 +3632,16 @@ class ReportCanvasComponent {
2995
3632
  return;
2996
3633
  }
2997
3634
  const activePage = this.activePage;
2998
- moveItemInArray(this.report.pages, event.previousIndex, event.currentIndex);
2999
- const positions = this.report.pages.map((page, order) => {
3635
+ const pages = this.pages();
3636
+ moveItemInArray(pages, event.previousIndex, event.currentIndex);
3637
+ this.pages.set(pages);
3638
+ const positions = pages.map((page, order) => {
3000
3639
  page.order = order;
3001
3640
  return { pageId: page.id, order };
3002
3641
  });
3003
- this.activePageIndex = activePage
3004
- ? this.report.pages.indexOf(activePage)
3005
- : this.activePageIndex;
3642
+ if (activePage) {
3643
+ this.activePageIndex.set(pages.indexOf(activePage));
3644
+ }
3006
3645
  this._reportData.savePagePositions(this.report.id, positions)
3007
3646
  .pipe(takeUntilDestroyed(this._destroyRef))
3008
3647
  .subscribe();
@@ -3016,19 +3655,17 @@ class ReportCanvasComponent {
3016
3655
  ?.applySettings(settings);
3017
3656
  }
3018
3657
  selectComponent(componentId) {
3019
- this.selectedComponentId = componentId;
3020
- this._cdRef.markForCheck();
3658
+ this.selectedComponentId.set(componentId);
3021
3659
  }
3022
3660
  onCanvasPointerDown() {
3023
3661
  // Background click (pan start) deselects.
3024
- if (this.selectedComponentId !== null) {
3662
+ if (this.selectedComponentId() !== null) {
3025
3663
  this.selectComponent(null);
3026
3664
  }
3027
3665
  }
3028
3666
  // ----- zoom toolbar ---------------------------------------------------------
3029
3667
  onZoomed(scale) {
3030
- this.zoom = scale;
3031
- this._cdRef.markForCheck();
3668
+ this.zoom.set(scale);
3032
3669
  }
3033
3670
  zoomIn() {
3034
3671
  this.zoomPan?.zoomIn();
@@ -3075,7 +3712,7 @@ class ReportCanvasComponent {
3075
3712
  component.order = order;
3076
3713
  return { componentId: component.id, order };
3077
3714
  });
3078
- this._cdRef.markForCheck();
3715
+ this.pages.set(this.pages());
3079
3716
  this._reportData.savePositions(this.report.id, positions)
3080
3717
  .pipe(takeUntilDestroyed(this._destroyRef))
3081
3718
  .subscribe();
@@ -3098,7 +3735,7 @@ class ReportCanvasComponent {
3098
3735
  // ----- smart guides ---------------------------------------------------------
3099
3736
  // Pixel feel of the snap, converted to inches at the current zoom.
3100
3737
  get _snapThreshold() {
3101
- return 8 / (PX_PER_INCH * Math.max(0.1, this.zoom));
3738
+ return 8 / (PX_PER_INCH * Math.max(0.1, this.zoom()));
3102
3739
  }
3103
3740
  _snapMove(component, x, y) {
3104
3741
  const { vertical, horizontal } = this._candidates(component);
@@ -3237,7 +3874,7 @@ class ReportCanvasComponent {
3237
3874
  this._guides?.nativeElement?.replaceChildren();
3238
3875
  }
3239
3876
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportCanvasComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3240
- 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", pageId: "pageId" }, outputs: { componentSettings: "componentSettings", reportChanged: "reportChanged", pageChanged: "pageChanged" }, 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 [class.master-editing]=\"isMasterEditing\"\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 <!-- The header and footer: the master's components, always freeform,\n drawn under the page's own. Out of the flow (absolute), so a flow\n page's rows are laid out as if it were not there. -->\n @if (isMasterShown && masterComponents.length) {\n <div\n class=\"master-layer\"\n [class.editable]=\"isMasterEditing\">\n @for (component of masterComponents; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isMasterEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\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 (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n </div>\n }\n\n <!-- The header and footer bands, as guides while editing. -->\n @if (editMode) {\n @if (bands.headerHeight) {\n <div\n class=\"band band-header\"\n [style.height.px]=\"bands.headerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Header</span>\n }\n </div>\n }\n @if (bands.footerHeight) {\n <div\n class=\"band band-footer\"\n [style.height.px]=\"bands.footerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Footer</span>\n }\n </div>\n }\n }\n\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]=\"isPageEditing\"\n [layout]=\"'flow'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\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 (removed)=\"onComponentRemoved($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]=\"isPageEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\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 (removed)=\"onComponentRemoved($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 && !masterMode()) {\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 </div>\n\n <div class=\"canvas-toolbar\">\n <!-- Tabs are one horizontal drop list: dragging a chip reorders the pages.\n The drop INDEX is computed along X (orientation), but the preview is\n deliberately left unbounded on both axes \u2014 pinning it to the row with\n cdkDragBoundary means the chip you are dragging covers the chips you\n are dragging it past, and you cannot see where it is going to land.\n Every page gets a chip, including a lone one: the chip is what tells\n you which page you are on, and a report that hid it left you unable to\n tell page 1 of 1 from a page that failed to load. Deleting the last\n page is refused server-side, so the kebab is safe to expose here. -->\n <div\n class=\"page-tabs\"\n cdkDropList\n cdkDropListOrientation=\"horizontal\"\n (cdkDropListDropped)=\"onPageDropped($event)\">\n @for (page of report?.pages ?? []; track page.id; let index = $index) {\n <!-- The chip is the drag handle AND the tab: clicking selects,\n dragging reorders. The kebab lives inside it, so the whole\n control stays one chip instead of growing a second button. -->\n <div\n cdkDrag\n class=\"page-tab\"\n [class.active]=\"index === activePageIndex\"\n (click)=\"selectPage(index)\">\n <span class=\"page-tab-label\">{{ pageLabel(page, index) }}</span>\n <!-- The kebab opens the menu rather than starting a drag or\n switching pages, so it swallows its own click. -->\n <span\n class=\"page-tab-menu\"\n [fsMenuTriggerFor]=\"pageMenu\"\n (click)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.stopPropagation()\">\n <mat-icon>more_vert</mat-icon>\n </span>\n </div>\n <!-- Outside the chip: cdkDrag clones its host into the drag preview,\n and a menu overlay anchored to a cloned trigger opens detached\n from the real tab. -->\n <fs-menu #pageMenu>\n <ng-template\n fs-menu-item\n (click)=\"renamePage(page)\">\n Edit name\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleMasterHidden(page)\">\n {{ page.masterHidden ? 'Show header & footer' : 'Hide header & footer' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"deletePage(page)\">\n Delete\n </ng-template>\n </fs-menu>\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;align-items:center;gap:4px;flex-wrap:wrap}.canvas-toolbar .page-tabs fs-menu{display:none}.canvas-toolbar .page-tabs .page-tab{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;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 .page-tab-label{white-space:nowrap}.canvas-toolbar .page-tabs .page-tab .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.canvas-toolbar .page-tabs .page-tab .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.canvas-toolbar .page-tabs .page-tab .page-tab-menu:hover{background:#0f172a14;opacity:1}.canvas-toolbar .page-tabs .page-tab.active .page-tab-menu:hover{background:#ffffff38}.canvas-toolbar .page-tabs .page-tab.add-page{gap:2px;padding:4px 14px 4px 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 .page-tabs .page-tab.cdk-drag-placeholder{background:transparent;border-style:dashed;border-color:#cbd2d9}.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-label,.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-menu{visibility:hidden}.canvas-toolbar .page-tabs .cdk-drag-animating,.canvas-toolbar .page-tabs.cdk-drop-list-dragging .page-tab:not(.cdk-drag-placeholder){transition:transform .18s cubic-bezier(0,0,.2,1)}.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-tab.cdk-drag-preview{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;color:#52606d;box-shadow:0 3px 10px #0f172a3d}.page-tab.cdk-drag-preview.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.page-tab.cdk-drag-preview .page-tab-label{white-space:nowrap}.page-tab.cdk-drag-preview .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.page-tab.cdk-drag-preview .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.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%}.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}.page.master-editing>app-report-component{opacity:.35;pointer-events:none}.master-layer{position:absolute;inset:0;pointer-events:none}.master-layer.editable{z-index:10}.master-layer.editable app-report-component{pointer-events:auto}.band{position:absolute;left:0;right:0;z-index:5;pointer-events:none;background:#2196f30a;border-color:#2196f373;border-style:dashed;border-width:0}.band.band-header{top:0;border-bottom-width:calc(1px / var(--canvas-zoom, 1))}.band.band-footer{bottom:0;border-top-width:calc(1px / var(--canvas-zoom, 1))}.band .band-label{position:absolute;top:calc(10px / var(--canvas-zoom, 1));left:calc(10px / var(--canvas-zoom, 1));transform:scale(calc(1 / var(--canvas-zoom, 1)));transform-origin:top left;color:#2196f3cc;font-weight:600;text-transform:uppercase;letter-spacing:.06em}.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: "ngmodule", type: DragDropModule }, { kind: "directive", type: i1$3.CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep", "cdkDropListElementContainer"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "directive", type: i1$3.CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer", "cdkDragScale"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }, { 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: FsMenuModule }, { kind: "component", type: i2$1.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$1.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2$1.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "ngmodule", type: FsZoomPanModule }, { kind: "component", type: i3.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", "tokens"], outputs: ["selectComponent", "positionChanged", "flowReorder", "openSettings", "removed"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3877
+ 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", pageId: "pageId" }, outputs: { componentSettings: "componentSettings", reportChanged: "reportChanged", pageChanged: "pageChanged" }, 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 wheel=\"modifier\"\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 [class.master-editing]=\"isMasterEditing\"\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 <!-- The header and footer: the master's components, always freeform,\n drawn under the page's own. Out of the flow (absolute), so a flow\n page's rows are laid out as if it were not there. -->\n @if (isMasterShown && masterComponents.length) {\n <div\n class=\"master-layer\"\n [class.editable]=\"isMasterEditing\">\n @for (component of masterComponents; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isMasterEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\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 (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n </div>\n }\n\n <!-- The header and footer bands, as guides while editing. -->\n @if (editMode) {\n @if (bands.headerHeight) {\n <div\n class=\"band band-header\"\n [style.height.px]=\"bands.headerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Header</span>\n }\n </div>\n }\n @if (bands.footerHeight) {\n <div\n class=\"band band-footer\"\n [style.height.px]=\"bands.footerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Footer</span>\n }\n </div>\n }\n }\n\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]=\"isPageEditing\"\n [layout]=\"'flow'\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\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 (removed)=\"onComponentRemoved($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]=\"isPageEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\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 (removed)=\"onComponentRemoved($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 && !masterMode()) {\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 </div>\n\n <div class=\"canvas-toolbar\">\n <!-- Tabs are one horizontal drop list: dragging a chip reorders the pages.\n The drop INDEX is computed along X (orientation), but the preview is\n deliberately left unbounded on both axes \u2014 pinning it to the row with\n cdkDragBoundary means the chip you are dragging covers the chips you\n are dragging it past, and you cannot see where it is going to land.\n Every page gets a chip, including a lone one: the chip is what tells\n you which page you are on, and a report that hid it left you unable to\n tell page 1 of 1 from a page that failed to load. Deleting the last\n page is refused server-side, so the kebab is safe to expose here. -->\n <div\n class=\"page-tabs\"\n cdkDropList\n cdkDropListOrientation=\"horizontal\"\n (cdkDropListDropped)=\"onPageDropped($event)\">\n @for (page of pages(); track page.id; let index = $index) {\n <!-- The chip is the drag handle AND the tab: clicking selects,\n dragging reorders. The kebab lives inside it, so the whole\n control stays one chip instead of growing a second button. -->\n <div\n cdkDrag\n class=\"page-tab\"\n [class.active]=\"index === activePageIndex()\"\n (click)=\"selectPage(index)\">\n <span class=\"page-tab-label\">{{ pageLabel(page, index) }}</span>\n <!-- The kebab opens the menu rather than starting a drag or\n switching pages, so it swallows its own click. -->\n <span\n class=\"page-tab-menu\"\n [fsMenuTriggerFor]=\"pageMenu\"\n (click)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.stopPropagation()\">\n <mat-icon>more_vert</mat-icon>\n </span>\n </div>\n <!-- Outside the chip: cdkDrag clones its host into the drag preview,\n and a menu overlay anchored to a cloned trigger opens detached\n from the real tab. -->\n <fs-menu #pageMenu>\n <ng-template\n fs-menu-item\n (click)=\"renamePage(page)\">\n Edit name\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleMasterHidden(page)\">\n {{ page.masterHidden ? 'Show header & footer' : 'Hide header & footer' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"deletePage(page)\">\n Delete\n </ng-template>\n </fs-menu>\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 (Ctrl/Cmd + scroll)\"\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 (Ctrl/Cmd + scroll)\"\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;align-items:center;gap:4px;flex-wrap:wrap}.canvas-toolbar .page-tabs fs-menu{display:none}.canvas-toolbar .page-tabs .page-tab{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;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 .page-tab-label{white-space:nowrap}.canvas-toolbar .page-tabs .page-tab .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.canvas-toolbar .page-tabs .page-tab .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.canvas-toolbar .page-tabs .page-tab .page-tab-menu:hover{background:#0f172a14;opacity:1}.canvas-toolbar .page-tabs .page-tab.active .page-tab-menu:hover{background:#ffffff38}.canvas-toolbar .page-tabs .page-tab.add-page{gap:2px;padding:4px 14px 4px 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 .page-tabs .page-tab.cdk-drag-placeholder{background:transparent;border-style:dashed;border-color:#cbd2d9}.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-label,.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-menu{visibility:hidden}.canvas-toolbar .page-tabs .cdk-drag-animating,.canvas-toolbar .page-tabs.cdk-drop-list-dragging .page-tab:not(.cdk-drag-placeholder){transition:transform .18s cubic-bezier(0,0,.2,1)}.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-tab.cdk-drag-preview{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;color:#52606d;box-shadow:0 3px 10px #0f172a3d}.page-tab.cdk-drag-preview.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.page-tab.cdk-drag-preview .page-tab-label{white-space:nowrap}.page-tab.cdk-drag-preview .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.page-tab.cdk-drag-preview .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.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%}.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}.page.master-editing>app-report-component{opacity:.35;pointer-events:none}.master-layer{position:absolute;inset:0;pointer-events:none}.master-layer.editable{z-index:10}.master-layer.editable app-report-component{pointer-events:auto}.band{position:absolute;left:0;right:0;z-index:5;pointer-events:none;background:#2196f30a;border-color:#2196f373;border-style:dashed;border-width:0}.band.band-header{top:0;border-bottom-width:calc(1px / var(--canvas-zoom, 1))}.band.band-footer{bottom:0;border-top-width:calc(1px / var(--canvas-zoom, 1))}.band .band-label{position:absolute;top:calc(10px / var(--canvas-zoom, 1));left:calc(10px / var(--canvas-zoom, 1));transform:scale(calc(1 / var(--canvas-zoom, 1)));transform-origin:top left;color:#2196f3cc;font-weight:600;text-transform:uppercase;letter-spacing:.06em}.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: "ngmodule", type: DragDropModule }, { kind: "directive", type: i1$3.CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep", "cdkDropListElementContainer"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "directive", type: i1$3.CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer", "cdkDragScale"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }, { 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: FsMenuModule }, { kind: "component", type: i2$1.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$1.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2$1.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "ngmodule", type: FsZoomPanModule }, { kind: "component", type: i3.FsZoomPanComponent, selector: "fs-zoom-pan", inputs: ["zoomMax", "zoomMin", "zoomScale", "zoomDefault", "zoomFactor", "zoomFit", "fitMode", "fitPosition", "fitPadding", "fitMaxScale", "wheel", "top", "left"], outputs: ["moved", "zoomed"] }, { kind: "component", type: ReportComponentComponent, selector: "app-report-component", inputs: ["reportId", "component", "groups", "editMode", "layout", "zoom", "snapper", "selected", "tokens"], outputs: ["selectComponent", "positionChanged", "flowReorder", "openSettings", "removed"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3241
3878
  }
3242
3879
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportCanvasComponent, decorators: [{
3243
3880
  type: Component,
@@ -3248,7 +3885,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
3248
3885
  FsMenuModule,
3249
3886
  FsZoomPanModule,
3250
3887
  ReportComponentComponent,
3251
- ], 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 [class.master-editing]=\"isMasterEditing\"\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 <!-- The header and footer: the master's components, always freeform,\n drawn under the page's own. Out of the flow (absolute), so a flow\n page's rows are laid out as if it were not there. -->\n @if (isMasterShown && masterComponents.length) {\n <div\n class=\"master-layer\"\n [class.editable]=\"isMasterEditing\">\n @for (component of masterComponents; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isMasterEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\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 (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n </div>\n }\n\n <!-- The header and footer bands, as guides while editing. -->\n @if (editMode) {\n @if (bands.headerHeight) {\n <div\n class=\"band band-header\"\n [style.height.px]=\"bands.headerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Header</span>\n }\n </div>\n }\n @if (bands.footerHeight) {\n <div\n class=\"band band-footer\"\n [style.height.px]=\"bands.footerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Footer</span>\n }\n </div>\n }\n }\n\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]=\"isPageEditing\"\n [layout]=\"'flow'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\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 (removed)=\"onComponentRemoved($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]=\"isPageEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\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 (removed)=\"onComponentRemoved($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 && !masterMode()) {\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 </div>\n\n <div class=\"canvas-toolbar\">\n <!-- Tabs are one horizontal drop list: dragging a chip reorders the pages.\n The drop INDEX is computed along X (orientation), but the preview is\n deliberately left unbounded on both axes \u2014 pinning it to the row with\n cdkDragBoundary means the chip you are dragging covers the chips you\n are dragging it past, and you cannot see where it is going to land.\n Every page gets a chip, including a lone one: the chip is what tells\n you which page you are on, and a report that hid it left you unable to\n tell page 1 of 1 from a page that failed to load. Deleting the last\n page is refused server-side, so the kebab is safe to expose here. -->\n <div\n class=\"page-tabs\"\n cdkDropList\n cdkDropListOrientation=\"horizontal\"\n (cdkDropListDropped)=\"onPageDropped($event)\">\n @for (page of report?.pages ?? []; track page.id; let index = $index) {\n <!-- The chip is the drag handle AND the tab: clicking selects,\n dragging reorders. The kebab lives inside it, so the whole\n control stays one chip instead of growing a second button. -->\n <div\n cdkDrag\n class=\"page-tab\"\n [class.active]=\"index === activePageIndex\"\n (click)=\"selectPage(index)\">\n <span class=\"page-tab-label\">{{ pageLabel(page, index) }}</span>\n <!-- The kebab opens the menu rather than starting a drag or\n switching pages, so it swallows its own click. -->\n <span\n class=\"page-tab-menu\"\n [fsMenuTriggerFor]=\"pageMenu\"\n (click)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.stopPropagation()\">\n <mat-icon>more_vert</mat-icon>\n </span>\n </div>\n <!-- Outside the chip: cdkDrag clones its host into the drag preview,\n and a menu overlay anchored to a cloned trigger opens detached\n from the real tab. -->\n <fs-menu #pageMenu>\n <ng-template\n fs-menu-item\n (click)=\"renamePage(page)\">\n Edit name\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleMasterHidden(page)\">\n {{ page.masterHidden ? 'Show header & footer' : 'Hide header & footer' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"deletePage(page)\">\n Delete\n </ng-template>\n </fs-menu>\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;align-items:center;gap:4px;flex-wrap:wrap}.canvas-toolbar .page-tabs fs-menu{display:none}.canvas-toolbar .page-tabs .page-tab{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;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 .page-tab-label{white-space:nowrap}.canvas-toolbar .page-tabs .page-tab .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.canvas-toolbar .page-tabs .page-tab .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.canvas-toolbar .page-tabs .page-tab .page-tab-menu:hover{background:#0f172a14;opacity:1}.canvas-toolbar .page-tabs .page-tab.active .page-tab-menu:hover{background:#ffffff38}.canvas-toolbar .page-tabs .page-tab.add-page{gap:2px;padding:4px 14px 4px 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 .page-tabs .page-tab.cdk-drag-placeholder{background:transparent;border-style:dashed;border-color:#cbd2d9}.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-label,.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-menu{visibility:hidden}.canvas-toolbar .page-tabs .cdk-drag-animating,.canvas-toolbar .page-tabs.cdk-drop-list-dragging .page-tab:not(.cdk-drag-placeholder){transition:transform .18s cubic-bezier(0,0,.2,1)}.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-tab.cdk-drag-preview{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;color:#52606d;box-shadow:0 3px 10px #0f172a3d}.page-tab.cdk-drag-preview.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.page-tab.cdk-drag-preview .page-tab-label{white-space:nowrap}.page-tab.cdk-drag-preview .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.page-tab.cdk-drag-preview .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.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%}.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}.page.master-editing>app-report-component{opacity:.35;pointer-events:none}.master-layer{position:absolute;inset:0;pointer-events:none}.master-layer.editable{z-index:10}.master-layer.editable app-report-component{pointer-events:auto}.band{position:absolute;left:0;right:0;z-index:5;pointer-events:none;background:#2196f30a;border-color:#2196f373;border-style:dashed;border-width:0}.band.band-header{top:0;border-bottom-width:calc(1px / var(--canvas-zoom, 1))}.band.band-footer{bottom:0;border-top-width:calc(1px / var(--canvas-zoom, 1))}.band .band-label{position:absolute;top:calc(10px / var(--canvas-zoom, 1));left:calc(10px / var(--canvas-zoom, 1));transform:scale(calc(1 / var(--canvas-zoom, 1)));transform-origin:top left;color:#2196f3cc;font-weight:600;text-transform:uppercase;letter-spacing:.06em}.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"] }]
3888
+ ], template: "<div class=\"canvas\">\n <div\n #viewport\n class=\"page-viewport\"\n (pointerdown)=\"onCanvasPointerDown()\">\n <fs-zoom-pan\n wheel=\"modifier\"\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 [class.master-editing]=\"isMasterEditing\"\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 <!-- The header and footer: the master's components, always freeform,\n drawn under the page's own. Out of the flow (absolute), so a flow\n page's rows are laid out as if it were not there. -->\n @if (isMasterShown && masterComponents.length) {\n <div\n class=\"master-layer\"\n [class.editable]=\"isMasterEditing\">\n @for (component of masterComponents; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"isMasterEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\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 (removed)=\"onComponentRemoved($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n </div>\n }\n\n <!-- The header and footer bands, as guides while editing. -->\n @if (editMode) {\n @if (bands.headerHeight) {\n <div\n class=\"band band-header\"\n [style.height.px]=\"bands.headerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Header</span>\n }\n </div>\n }\n @if (bands.footerHeight) {\n <div\n class=\"band band-footer\"\n [style.height.px]=\"bands.footerHeight * PX\">\n @if (isMasterEditing) {\n <span class=\"band-label small\">Footer</span>\n }\n </div>\n }\n }\n\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]=\"isPageEditing\"\n [layout]=\"'flow'\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\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 (removed)=\"onComponentRemoved($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]=\"isPageEditing\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom()\"\n [snapper]=\"snapper\"\n [tokens]=\"tokens\"\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 (removed)=\"onComponentRemoved($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 && !masterMode()) {\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 </div>\n\n <div class=\"canvas-toolbar\">\n <!-- Tabs are one horizontal drop list: dragging a chip reorders the pages.\n The drop INDEX is computed along X (orientation), but the preview is\n deliberately left unbounded on both axes \u2014 pinning it to the row with\n cdkDragBoundary means the chip you are dragging covers the chips you\n are dragging it past, and you cannot see where it is going to land.\n Every page gets a chip, including a lone one: the chip is what tells\n you which page you are on, and a report that hid it left you unable to\n tell page 1 of 1 from a page that failed to load. Deleting the last\n page is refused server-side, so the kebab is safe to expose here. -->\n <div\n class=\"page-tabs\"\n cdkDropList\n cdkDropListOrientation=\"horizontal\"\n (cdkDropListDropped)=\"onPageDropped($event)\">\n @for (page of pages(); track page.id; let index = $index) {\n <!-- The chip is the drag handle AND the tab: clicking selects,\n dragging reorders. The kebab lives inside it, so the whole\n control stays one chip instead of growing a second button. -->\n <div\n cdkDrag\n class=\"page-tab\"\n [class.active]=\"index === activePageIndex()\"\n (click)=\"selectPage(index)\">\n <span class=\"page-tab-label\">{{ pageLabel(page, index) }}</span>\n <!-- The kebab opens the menu rather than starting a drag or\n switching pages, so it swallows its own click. -->\n <span\n class=\"page-tab-menu\"\n [fsMenuTriggerFor]=\"pageMenu\"\n (click)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.stopPropagation()\">\n <mat-icon>more_vert</mat-icon>\n </span>\n </div>\n <!-- Outside the chip: cdkDrag clones its host into the drag preview,\n and a menu overlay anchored to a cloned trigger opens detached\n from the real tab. -->\n <fs-menu #pageMenu>\n <ng-template\n fs-menu-item\n (click)=\"renamePage(page)\">\n Edit name\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleMasterHidden(page)\">\n {{ page.masterHidden ? 'Show header & footer' : 'Hide header & footer' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"deletePage(page)\">\n Delete\n </ng-template>\n </fs-menu>\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 (Ctrl/Cmd + scroll)\"\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 (Ctrl/Cmd + scroll)\"\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;align-items:center;gap:4px;flex-wrap:wrap}.canvas-toolbar .page-tabs fs-menu{display:none}.canvas-toolbar .page-tabs .page-tab{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;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 .page-tab-label{white-space:nowrap}.canvas-toolbar .page-tabs .page-tab .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.canvas-toolbar .page-tabs .page-tab .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.canvas-toolbar .page-tabs .page-tab .page-tab-menu:hover{background:#0f172a14;opacity:1}.canvas-toolbar .page-tabs .page-tab.active .page-tab-menu:hover{background:#ffffff38}.canvas-toolbar .page-tabs .page-tab.add-page{gap:2px;padding:4px 14px 4px 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 .page-tabs .page-tab.cdk-drag-placeholder{background:transparent;border-style:dashed;border-color:#cbd2d9}.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-label,.canvas-toolbar .page-tabs .page-tab.cdk-drag-placeholder .page-tab-menu{visibility:hidden}.canvas-toolbar .page-tabs .cdk-drag-animating,.canvas-toolbar .page-tabs.cdk-drop-list-dragging .page-tab:not(.cdk-drag-placeholder){transition:transform .18s cubic-bezier(0,0,.2,1)}.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-tab.cdk-drag-preview{display:inline-flex;align-items:center;box-sizing:border-box;border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 6px 4px 14px;font-size:12px;line-height:16px;color:#52606d;box-shadow:0 3px 10px #0f172a3d}.page-tab.cdk-drag-preview.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.page-tab.cdk-drag-preview .page-tab-label{white-space:nowrap}.page-tab.cdk-drag-preview .page-tab-menu{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:16px;height:16px;margin-left:2px;border-radius:50%;opacity:.75}.page-tab.cdk-drag-preview .page-tab-menu mat-icon{font-size:14px;width:14px;height:14px;line-height:14px}.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%}.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}.page.master-editing>app-report-component{opacity:.35;pointer-events:none}.master-layer{position:absolute;inset:0;pointer-events:none}.master-layer.editable{z-index:10}.master-layer.editable app-report-component{pointer-events:auto}.band{position:absolute;left:0;right:0;z-index:5;pointer-events:none;background:#2196f30a;border-color:#2196f373;border-style:dashed;border-width:0}.band.band-header{top:0;border-bottom-width:calc(1px / var(--canvas-zoom, 1))}.band.band-footer{bottom:0;border-top-width:calc(1px / var(--canvas-zoom, 1))}.band .band-label{position:absolute;top:calc(10px / var(--canvas-zoom, 1));left:calc(10px / var(--canvas-zoom, 1));transform:scale(calc(1 / var(--canvas-zoom, 1)));transform-origin:top left;color:#2196f3cc;font-weight:600;text-transform:uppercase;letter-spacing:.06em}.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"] }]
3252
3889
  }], propDecorators: { report: [{
3253
3890
  type: Input
3254
3891
  }], editMode: [{
@@ -3305,18 +3942,22 @@ class ComponentSettingsComponent {
3305
3942
  // ---- SQL tab state ----
3306
3943
  // The statement, once fetched. Null while it is in flight, and null when the
3307
3944
  // component's document could not be compiled (sqlError then says why).
3308
- sql = null;
3309
- // True when the statement is a DERIVED baseline: no filters, UTC, the
3310
- // authored grain. The tab says so, because the numbers on screen come from
3311
- // this statement plus the viewer's own choices and the two can differ.
3312
- sqlBaseline = false;
3313
- sqlError = null;
3945
+ sql = signal(null);
3946
+ // True when the statement is a DERIVED baseline: no filters, the authored
3947
+ // grain, times in the report's effective zone. The tab says so, because the
3948
+ // numbers on screen come from this statement plus the viewer's own choices
3949
+ // and the two can differ.
3950
+ sqlBaseline = signal(false);
3951
+ // The zone the statement was compiled in: the report's effective zone, sent
3952
+ // with the SQL. A server too old to send it compiled in UTC.
3953
+ sqlTimezone = signal('UTC');
3954
+ sqlError = signal(null);
3314
3955
  // ---- filter management state ----
3315
3956
  // One row per filterable column. The two booleans drive the toggles; we flip
3316
3957
  // them optimistically on click (so the UI never blinks while the request is
3317
3958
  // in flight) and reconcile from the server on reload.
3318
- rows = [];
3319
- columnsError = null;
3959
+ rows = signal([]);
3960
+ columnsError = signal(null);
3320
3961
  // The raw inputs the rows are built from.
3321
3962
  _filterableColumns = [];
3322
3963
  _filters = [];
@@ -3332,7 +3973,6 @@ class ComponentSettingsComponent {
3332
3973
  _message = inject(FsMessage);
3333
3974
  _prompt = inject(FsPrompt);
3334
3975
  _destroyRef = inject(DestroyRef);
3335
- _cdRef = inject(ChangeDetectorRef);
3336
3976
  ngOnInit() {
3337
3977
  this.report = this._data.report;
3338
3978
  this.component = this._data.component;
@@ -3379,7 +4019,7 @@ class ComponentSettingsComponent {
3379
4019
  },
3380
4020
  };
3381
4021
  return this._reportData.updateComponent(this.report.id, this.component.id, { ...settings })
3382
- .pipe(tap(() => {
4022
+ .pipe(tap$1(() => {
3383
4023
  this._message.success('Component saved');
3384
4024
  this._dialogRef.close({ settings });
3385
4025
  }));
@@ -3429,7 +4069,7 @@ class ComponentSettingsComponent {
3429
4069
  // Build the row view-model from the loaded columns + filters/groups. Each
3430
4070
  // row's toggles reflect where its filter group is currently exposed.
3431
4071
  _rebuildRows() {
3432
- this.rows = this._filterableColumns.map((column) => {
4072
+ this.rows.set(this._filterableColumns.map((column) => {
3433
4073
  const filter = this._filters.find((candidate) => candidate.filterColumn === column.column);
3434
4074
  const level = filter ? this._groupsById.get(filter.filterGroupId)?.level : undefined;
3435
4075
  return {
@@ -3441,7 +4081,7 @@ class ComponentSettingsComponent {
3441
4081
  report: level === 'report' || level === 'both',
3442
4082
  component: level === 'component' || level === 'both',
3443
4083
  };
3444
- });
4084
+ }));
3445
4085
  }
3446
4086
  // After any filter mutation: re-fetch the report so the dialog's filter list
3447
4087
  // (and the parent canvas, on close) reflect the new groups/levels.
@@ -3451,7 +4091,6 @@ class ComponentSettingsComponent {
3451
4091
  .subscribe((report) => {
3452
4092
  this.report = report;
3453
4093
  this._seedFilters(report, this.component.id);
3454
- this._cdRef.markForCheck();
3455
4094
  });
3456
4095
  }
3457
4096
  _mutationError(error) {
@@ -3481,15 +4120,14 @@ class ComponentSettingsComponent {
3481
4120
  this._reportData.componentSql(this.report.id, this.component.id)
3482
4121
  .subscribe({
3483
4122
  next: (preview) => {
3484
- this.sql = preview?.sql ?? null;
3485
- this.sqlBaseline = !!preview?.baseline;
3486
- this.sqlError = preview?.error ?? null;
3487
- this._cdRef.markForCheck();
4123
+ this.sql.set(preview?.sql ?? null);
4124
+ this.sqlBaseline.set(!!preview?.baseline);
4125
+ this.sqlTimezone.set(preview?.timezone || 'UTC');
4126
+ this.sqlError.set(preview?.error ?? null);
3488
4127
  },
3489
4128
  error: (error) => {
3490
- this.sql = null;
3491
- this.sqlError = error?.error?.message ?? 'Could not read the component SQL';
3492
- this._cdRef.markForCheck();
4129
+ this.sql.set(null);
4130
+ this.sqlError.set(error?.error?.message ?? 'Could not read the component SQL');
3493
4131
  },
3494
4132
  });
3495
4133
  }
@@ -3499,11 +4137,9 @@ class ComponentSettingsComponent {
3499
4137
  next: (columns) => {
3500
4138
  this._filterableColumns = columns ?? [];
3501
4139
  this._rebuildRows();
3502
- this._cdRef.markForCheck();
3503
4140
  },
3504
4141
  error: (error) => {
3505
- this.columnsError = error?.error?.message ?? 'Could not read the component columns';
3506
- this._cdRef.markForCheck();
4142
+ this.columnsError.set(error?.error?.message ?? 'Could not read the component columns');
3507
4143
  },
3508
4144
  });
3509
4145
  }
@@ -3515,7 +4151,7 @@ class ComponentSettingsComponent {
3515
4151
  .replace(/\b\w/g, (character) => character.toUpperCase());
3516
4152
  }
3517
4153
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentSettingsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3518
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentSettingsComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n {{ component.title || 'Component' }}\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Title\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"title\"\n name=\"title\">\n </mat-form-field>\n <div class=\"subheading-2\">\n Padding\n </div>\n <div class=\"geometry-row\">\n <mat-form-field>\n <mat-label>\n Top\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingTop\"\n name=\"paddingTop\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Right\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingRight\"\n name=\"paddingRight\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Bottom\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingBottom\"\n name=\"paddingBottom\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Left\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingLeft\"\n name=\"paddingLeft\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n </div>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"tab-body\">\n @if (columnsError) {\n <div class=\"filters-empty error\">\n {{ columnsError }}\n </div>\n } @else if (rows.length) {\n <!-- Every filterable column. Toggle where its control shows: in\n the report bar, on this component, both, or neither. The\n interface (date range vs value picker) follows the data. -->\n <table class=\"filter-table\">\n <thead>\n <tr>\n <th class=\"filter-col-name\">\n Filter\n </th>\n <th class=\"filter-col-type\">\n Type\n </th>\n <th class=\"filter-col-toggle\">\n Report bar\n </th>\n <th class=\"filter-col-toggle\">\n This component\n </th>\n </tr>\n </thead>\n <tbody>\n @for (row of rows; track row.column) {\n <tr>\n <td class=\"filter-col-name\">\n {{ row.label }}\n </td>\n <td class=\"filter-col-type\">\n {{ typeLabel(row.type) }}\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.report\"\n (ngModelChange)=\"setExposure(row, 'report', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show in the report bar\">\n </mat-slide-toggle>\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.component\"\n (ngModelChange)=\"setExposure(row, 'component', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show on this component\">\n </mat-slide-toggle>\n </td>\n </tr>\n }\n </tbody>\n </table>\n } @else {\n <div class=\"filters-empty\">\n This component has no filterable columns yet \u2014 give it some SQL first.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"SQL\"\n name=\"sql\">\n <div class=\"tab-body\">\n @if (sqlError) {\n <div class=\"filters-empty error\">\n {{ sqlError }}\n </div>\n } @else if (sql) {\n @if (sqlBaseline) {\n <!-- A derived component's statement is compiled from its\n document with no viewer state, so it is not byte-for-byte\n what produced the numbers on screen. Said here rather than\n left for the reader to discover. -->\n <div class=\"filters-empty\">\n Compiled from this component's query \u2014 no filters applied, times in UTC.\n </div>\n }\n <pre class=\"code-block\">{{ sql }}</pre>\n } @else {\n <div class=\"filters-empty\">\n This component has no SQL yet.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"Config\"\n name=\"config\">\n <div class=\"tab-body\">\n <pre class=\"code-block\">\n {{ configJson }}\n </pre>\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <!-- Save | Delete | Cancel, the standard order. fs-form-dialog-actions\n renders its own Save/Done BEFORE the projected content, which would\n put Delete after Cancel, so `[save]=\"false\"` foots the row here and\n all three buttons are projected in order. Save submits the Settings\n tab; the other tabs persist as you touch them, so they get Done. -->\n <fs-form-dialog-actions [save]=\"false\">\n @if (selectedTab === 'settings') {\n <button\n mat-button\n type=\"submit\"\n color=\"primary\">\n Save\n </button>\n } @else {\n <button\n mat-button\n type=\"button\"\n color=\"primary\"\n [mat-dialog-close]=\"null\">\n Done\n </button>\n }\n <button\n mat-button\n type=\"button\"\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n <button\n mat-button\n type=\"button\"\n class=\"cancel-button\"\n [mat-dialog-close]=\"null\">\n Cancel\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}.geometry-row{display:flex;gap:8px}.geometry-row mat-form-field{flex:1}.filter-table{border-spacing:0;width:100%;border-collapse:collapse}.filter-table thead th{color:#8f8f8f;font-weight:400;font-size:85%;text-align:left;padding:8px 16px}.filter-table tbody tr{clip-path:xywh(0 3px 100% calc(100% - 6px) round 10px)}.filter-table tbody td{border:none;background-color:#fafafa;padding:8px 16px;vertical-align:middle;text-align:left}.filter-table .filter-col-name{font-weight:600;color:#1f2933}.filter-table .filter-col-type{color:#7b8794;width:120px}.filter-table .filter-col-toggle,.filter-table th.filter-col-toggle{width:130px;text-align:center}.filters-empty{font-size:13px;color:#7b8794;padding:8px 0 4px}.filters-empty.error{color:#e15759}.code-block{margin:0;padding:10px 12px;background:#1f2933;color:#e4e7eb;border-radius:6px;font-family:JetBrains Mono,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow:auto}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$2.FsFormDirective, selector: "[fsForm]", inputs: ["wrapperSelector", "messageSelector", "hintSelector", "labelSelector", "autocomplete", "shortcuts", "confirm", "confirmDialog", "confirmDrawer", "confirmBrowser", "dirtySubmitButton", "submit", "successDelay", "errorDelay", "deactivationGuard"], outputs: ["fsForm", "invalid", "valid", "submitted", "reseted", "cleared"], exportAs: ["fsForm"] }, { kind: "directive", type: i2$2.FsFormMinDirective, selector: "[fsFormMin]", inputs: ["fsFormMin", "fsFormMinMessage"] }, { kind: "component", type: i2$2.FsFormDialogActionsComponent, selector: "fs-form-dialog-actions", inputs: ["save", "create", "close", "done", "closeData", "name"] }, { kind: "directive", type: i2$2.FsFormNoFsValidatorsDirective, selector: "[ngModel]:not([required]):not([fsFormRequired]):not([fsFormCompare]):not([fsFormDateRange]):not([fsFormEmail]):not([fsFormEmails]):not([fsFormFunction]):not([fsFormGreater]):not([fsFormGreaterEqual]):not([fsFormInteger]):not([fsFormLesser]):not([fsFormMax]):not([fsFormMaxLength]):not([fsFormMin]):not([fsFormMinLength]):not([fsFormNumeric]):not([fsFormPattern]):not([fsFormPhone]):not([fsFormUrl]):not([validate])" }, { kind: "directive", type: i2$2.FsButtonDirective, selector: "[mat-raised-button],[mat-button],[mat-flat-button],[mat-stroked-button]", inputs: ["name", "dirtySubmit"] }, { kind: "ngmodule", type: FsMaskModule }, { kind: "directive", type: i3$1.FsMaskNumberDirective, selector: "[fsMaskNumber]" }, { kind: "ngmodule", type: FsDialogModule }, { kind: "component", type: i3$2.FsDialogComponent, selector: "fs-dialog", inputs: ["mobileMode", "mobileButtonPlacement", "mobileWidth", "mode", "buttonLayout"] }, { kind: "ngmodule", type: FsLabelModule }, { kind: "ngmodule", type: FsTabsModule }, { kind: "directive", type: i5.FsTabsHeaderTabGroupDirective, selector: "mat-tab-group, matTabGroup, [matTabGroup]", inputs: ["orientation", "selected", "selectedData"], outputs: ["selectedChange", "selectedDataChange"], exportAs: ["fsTabsHeaderTabGroup"] }, { kind: "directive", type: i5.FsTabsTabDirective, selector: "mat-tab,matTab", inputs: ["name", "data"], exportAs: ["fsTabsTab"] }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i6.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass"], exportAs: ["matTab"] }, { kind: "component", type: i6.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "directive", type: MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "directive", type: MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: MatDialogClose, selector: "[mat-dialog-close], [matDialogClose]", inputs: ["aria-label", "type", "mat-dialog-close", "matDialogClose"], exportAs: ["matDialogClose"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly"], exportAs: ["matInput"] }, { kind: "component", type: MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon", "disabledInteractive"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4154
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentSettingsComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n {{ component.title || 'Component' }}\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Title\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"title\"\n name=\"title\">\n </mat-form-field>\n <div class=\"subheading-2\">\n Padding\n </div>\n <div class=\"geometry-row\">\n <mat-form-field>\n <mat-label>\n Top\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingTop\"\n name=\"paddingTop\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Right\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingRight\"\n name=\"paddingRight\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Bottom\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingBottom\"\n name=\"paddingBottom\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Left\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingLeft\"\n name=\"paddingLeft\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n </div>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"tab-body\">\n @if (columnsError()) {\n <div class=\"filters-empty error\">\n {{ columnsError() }}\n </div>\n } @else if (rows().length) {\n <!-- Every filterable column. Toggle where its control shows: in\n the report bar, on this component, both, or neither. The\n interface (date range vs value picker) follows the data. -->\n <table class=\"filter-table\">\n <thead>\n <tr>\n <th class=\"filter-col-name\">\n Filter\n </th>\n <th class=\"filter-col-type\">\n Type\n </th>\n <th class=\"filter-col-toggle\">\n Report bar\n </th>\n <th class=\"filter-col-toggle\">\n This component\n </th>\n </tr>\n </thead>\n <tbody>\n @for (row of rows(); track row.column) {\n <tr>\n <td class=\"filter-col-name\">\n {{ row.label }}\n </td>\n <td class=\"filter-col-type\">\n {{ typeLabel(row.type) }}\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.report\"\n (ngModelChange)=\"setExposure(row, 'report', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show in the report bar\">\n </mat-slide-toggle>\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.component\"\n (ngModelChange)=\"setExposure(row, 'component', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show on this component\">\n </mat-slide-toggle>\n </td>\n </tr>\n }\n </tbody>\n </table>\n } @else {\n <div class=\"filters-empty\">\n This component has no filterable columns yet \u2014 give it some SQL first.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"SQL\"\n name=\"sql\">\n <div class=\"tab-body\">\n @if (sqlError()) {\n <div class=\"filters-empty error\">\n {{ sqlError() }}\n </div>\n } @else if (sql()) {\n @if (sqlBaseline()) {\n <!-- A derived component's statement is compiled from its\n document with no viewer state, so it is not byte-for-byte\n what produced the numbers on screen. Said here rather than\n left for the reader to discover. -->\n <div class=\"filters-empty\">\n Compiled from this component's query \u2014 no filters applied, times in {{ sqlTimezone() }}.\n </div>\n }\n <pre class=\"code-block\">{{ sql() }}</pre>\n } @else {\n <div class=\"filters-empty\">\n This component has no SQL yet.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"Config\"\n name=\"config\">\n <div class=\"tab-body\">\n <pre class=\"code-block\">\n {{ configJson }}\n </pre>\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <!-- Save | Delete | Cancel, the standard order. fs-form-dialog-actions\n renders its own Save/Done BEFORE the projected content, which would\n put Delete after Cancel, so `[save]=\"false\"` foots the row here and\n all three buttons are projected in order. Save submits the Settings\n tab; the other tabs persist as you touch them, so they get Done. -->\n <fs-form-dialog-actions [save]=\"false\">\n @if (selectedTab === 'settings') {\n <button\n mat-button\n type=\"submit\"\n color=\"primary\">\n Save\n </button>\n } @else {\n <button\n mat-button\n type=\"button\"\n color=\"primary\"\n [mat-dialog-close]=\"null\">\n Done\n </button>\n }\n <button\n mat-button\n type=\"button\"\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n <button\n mat-button\n type=\"button\"\n class=\"cancel-button\"\n [mat-dialog-close]=\"null\">\n Cancel\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}.geometry-row{display:flex;gap:8px}.geometry-row mat-form-field{flex:1}.filter-table{border-spacing:0;width:100%;border-collapse:collapse}.filter-table thead th{color:#8f8f8f;font-weight:400;font-size:85%;text-align:left;padding:8px 16px}.filter-table tbody tr{clip-path:xywh(0 3px 100% calc(100% - 6px) round 10px)}.filter-table tbody td{border:none;background-color:#fafafa;padding:8px 16px;vertical-align:middle;text-align:left}.filter-table .filter-col-name{font-weight:600;color:#1f2933}.filter-table .filter-col-type{color:#7b8794;width:120px}.filter-table .filter-col-toggle,.filter-table th.filter-col-toggle{width:130px;text-align:center}.filters-empty{font-size:13px;color:#7b8794;padding:8px 0 4px}.filters-empty.error{color:#e15759}.code-block{margin:0;padding:10px 12px;background:#1f2933;color:#e4e7eb;border-radius:6px;font-family:JetBrains Mono,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow:auto}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$2.FsFormDirective, selector: "[fsForm]", inputs: ["wrapperSelector", "messageSelector", "hintSelector", "labelSelector", "autocomplete", "shortcuts", "confirm", "confirmDialog", "confirmDrawer", "confirmBrowser", "dirtySubmitButton", "submit", "successDelay", "errorDelay", "deactivationGuard"], outputs: ["fsForm", "invalid", "valid", "submitted", "reseted", "cleared"], exportAs: ["fsForm"] }, { kind: "directive", type: i2$2.FsFormMinDirective, selector: "[fsFormMin]", inputs: ["fsFormMin", "fsFormMinMessage"] }, { kind: "component", type: i2$2.FsFormDialogActionsComponent, selector: "fs-form-dialog-actions", inputs: ["save", "create", "close", "done", "closeData", "name"] }, { kind: "directive", type: i2$2.FsFormNoFsValidatorsDirective, selector: "[ngModel]:not([required]):not([fsFormRequired]):not([fsFormCompare]):not([fsFormDateRange]):not([fsFormEmail]):not([fsFormEmails]):not([fsFormFunction]):not([fsFormGreater]):not([fsFormGreaterEqual]):not([fsFormInteger]):not([fsFormLesser]):not([fsFormMax]):not([fsFormMaxLength]):not([fsFormMin]):not([fsFormMinLength]):not([fsFormNumeric]):not([fsFormPattern]):not([fsFormPhone]):not([fsFormUrl]):not([validate])" }, { kind: "directive", type: i2$2.FsButtonDirective, selector: "[mat-raised-button],[mat-button],[mat-flat-button],[mat-stroked-button]", inputs: ["name", "dirtySubmit"] }, { kind: "ngmodule", type: FsMaskModule }, { kind: "directive", type: i3$1.FsMaskNumberDirective, selector: "[fsMaskNumber]" }, { kind: "ngmodule", type: FsDialogModule }, { kind: "component", type: i3$2.FsDialogComponent, selector: "fs-dialog", inputs: ["mobileMode", "mobileButtonPlacement", "mobileWidth", "mode", "buttonLayout"] }, { kind: "ngmodule", type: FsLabelModule }, { kind: "ngmodule", type: FsTabsModule }, { kind: "directive", type: i5.FsTabsHeaderTabGroupDirective, selector: "mat-tab-group, matTabGroup, [matTabGroup]", inputs: ["orientation", "selected", "selectedData"], outputs: ["selectedChange", "selectedDataChange"], exportAs: ["fsTabsHeaderTabGroup"] }, { kind: "directive", type: i5.FsTabsTabDirective, selector: "mat-tab,matTab", inputs: ["name", "data"], exportAs: ["fsTabsTab"] }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i6.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass"], exportAs: ["matTab"] }, { kind: "component", type: i6.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "directive", type: MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "directive", type: MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: MatDialogClose, selector: "[mat-dialog-close], [matDialogClose]", inputs: ["aria-label", "type", "mat-dialog-close", "matDialogClose"], exportAs: ["matDialogClose"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly"], exportAs: ["matInput"] }, { kind: "component", type: MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon", "disabledInteractive"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3519
4155
  }
3520
4156
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentSettingsComponent, decorators: [{
3521
4157
  type: Component,
@@ -3538,46 +4174,46 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
3538
4174
  MatInput,
3539
4175
  MatSlideToggle,
3540
4176
  MatTooltip,
3541
- ], template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n {{ component.title || 'Component' }}\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Title\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"title\"\n name=\"title\">\n </mat-form-field>\n <div class=\"subheading-2\">\n Padding\n </div>\n <div class=\"geometry-row\">\n <mat-form-field>\n <mat-label>\n Top\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingTop\"\n name=\"paddingTop\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Right\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingRight\"\n name=\"paddingRight\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Bottom\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingBottom\"\n name=\"paddingBottom\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Left\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingLeft\"\n name=\"paddingLeft\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n </div>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"tab-body\">\n @if (columnsError) {\n <div class=\"filters-empty error\">\n {{ columnsError }}\n </div>\n } @else if (rows.length) {\n <!-- Every filterable column. Toggle where its control shows: in\n the report bar, on this component, both, or neither. The\n interface (date range vs value picker) follows the data. -->\n <table class=\"filter-table\">\n <thead>\n <tr>\n <th class=\"filter-col-name\">\n Filter\n </th>\n <th class=\"filter-col-type\">\n Type\n </th>\n <th class=\"filter-col-toggle\">\n Report bar\n </th>\n <th class=\"filter-col-toggle\">\n This component\n </th>\n </tr>\n </thead>\n <tbody>\n @for (row of rows; track row.column) {\n <tr>\n <td class=\"filter-col-name\">\n {{ row.label }}\n </td>\n <td class=\"filter-col-type\">\n {{ typeLabel(row.type) }}\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.report\"\n (ngModelChange)=\"setExposure(row, 'report', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show in the report bar\">\n </mat-slide-toggle>\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.component\"\n (ngModelChange)=\"setExposure(row, 'component', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show on this component\">\n </mat-slide-toggle>\n </td>\n </tr>\n }\n </tbody>\n </table>\n } @else {\n <div class=\"filters-empty\">\n This component has no filterable columns yet \u2014 give it some SQL first.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"SQL\"\n name=\"sql\">\n <div class=\"tab-body\">\n @if (sqlError) {\n <div class=\"filters-empty error\">\n {{ sqlError }}\n </div>\n } @else if (sql) {\n @if (sqlBaseline) {\n <!-- A derived component's statement is compiled from its\n document with no viewer state, so it is not byte-for-byte\n what produced the numbers on screen. Said here rather than\n left for the reader to discover. -->\n <div class=\"filters-empty\">\n Compiled from this component's query \u2014 no filters applied, times in UTC.\n </div>\n }\n <pre class=\"code-block\">{{ sql }}</pre>\n } @else {\n <div class=\"filters-empty\">\n This component has no SQL yet.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"Config\"\n name=\"config\">\n <div class=\"tab-body\">\n <pre class=\"code-block\">\n {{ configJson }}\n </pre>\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <!-- Save | Delete | Cancel, the standard order. fs-form-dialog-actions\n renders its own Save/Done BEFORE the projected content, which would\n put Delete after Cancel, so `[save]=\"false\"` foots the row here and\n all three buttons are projected in order. Save submits the Settings\n tab; the other tabs persist as you touch them, so they get Done. -->\n <fs-form-dialog-actions [save]=\"false\">\n @if (selectedTab === 'settings') {\n <button\n mat-button\n type=\"submit\"\n color=\"primary\">\n Save\n </button>\n } @else {\n <button\n mat-button\n type=\"button\"\n color=\"primary\"\n [mat-dialog-close]=\"null\">\n Done\n </button>\n }\n <button\n mat-button\n type=\"button\"\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n <button\n mat-button\n type=\"button\"\n class=\"cancel-button\"\n [mat-dialog-close]=\"null\">\n Cancel\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}.geometry-row{display:flex;gap:8px}.geometry-row mat-form-field{flex:1}.filter-table{border-spacing:0;width:100%;border-collapse:collapse}.filter-table thead th{color:#8f8f8f;font-weight:400;font-size:85%;text-align:left;padding:8px 16px}.filter-table tbody tr{clip-path:xywh(0 3px 100% calc(100% - 6px) round 10px)}.filter-table tbody td{border:none;background-color:#fafafa;padding:8px 16px;vertical-align:middle;text-align:left}.filter-table .filter-col-name{font-weight:600;color:#1f2933}.filter-table .filter-col-type{color:#7b8794;width:120px}.filter-table .filter-col-toggle,.filter-table th.filter-col-toggle{width:130px;text-align:center}.filters-empty{font-size:13px;color:#7b8794;padding:8px 0 4px}.filters-empty.error{color:#e15759}.code-block{margin:0;padding:10px 12px;background:#1f2933;color:#e4e7eb;border-radius:6px;font-family:JetBrains Mono,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow:auto}\n"] }]
4177
+ ], template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n {{ component.title || 'Component' }}\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Title\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"title\"\n name=\"title\">\n </mat-form-field>\n <div class=\"subheading-2\">\n Padding\n </div>\n <div class=\"geometry-row\">\n <mat-form-field>\n <mat-label>\n Top\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingTop\"\n name=\"paddingTop\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Right\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingRight\"\n name=\"paddingRight\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Bottom\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingBottom\"\n name=\"paddingBottom\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Left\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"3\"\n [(ngModel)]=\"paddingLeft\"\n name=\"paddingLeft\"\n [fsFormMin]=\"0\">\n <div matTextSuffix>\n in\n </div>\n </mat-form-field>\n </div>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"tab-body\">\n @if (columnsError()) {\n <div class=\"filters-empty error\">\n {{ columnsError() }}\n </div>\n } @else if (rows().length) {\n <!-- Every filterable column. Toggle where its control shows: in\n the report bar, on this component, both, or neither. The\n interface (date range vs value picker) follows the data. -->\n <table class=\"filter-table\">\n <thead>\n <tr>\n <th class=\"filter-col-name\">\n Filter\n </th>\n <th class=\"filter-col-type\">\n Type\n </th>\n <th class=\"filter-col-toggle\">\n Report bar\n </th>\n <th class=\"filter-col-toggle\">\n This component\n </th>\n </tr>\n </thead>\n <tbody>\n @for (row of rows(); track row.column) {\n <tr>\n <td class=\"filter-col-name\">\n {{ row.label }}\n </td>\n <td class=\"filter-col-type\">\n {{ typeLabel(row.type) }}\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.report\"\n (ngModelChange)=\"setExposure(row, 'report', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show in the report bar\">\n </mat-slide-toggle>\n </td>\n <td class=\"filter-col-toggle\">\n <mat-slide-toggle\n [ngModel]=\"row.component\"\n (ngModelChange)=\"setExposure(row, 'component', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show on this component\">\n </mat-slide-toggle>\n </td>\n </tr>\n }\n </tbody>\n </table>\n } @else {\n <div class=\"filters-empty\">\n This component has no filterable columns yet \u2014 give it some SQL first.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"SQL\"\n name=\"sql\">\n <div class=\"tab-body\">\n @if (sqlError()) {\n <div class=\"filters-empty error\">\n {{ sqlError() }}\n </div>\n } @else if (sql()) {\n @if (sqlBaseline()) {\n <!-- A derived component's statement is compiled from its\n document with no viewer state, so it is not byte-for-byte\n what produced the numbers on screen. Said here rather than\n left for the reader to discover. -->\n <div class=\"filters-empty\">\n Compiled from this component's query \u2014 no filters applied, times in {{ sqlTimezone() }}.\n </div>\n }\n <pre class=\"code-block\">{{ sql() }}</pre>\n } @else {\n <div class=\"filters-empty\">\n This component has no SQL yet.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"Config\"\n name=\"config\">\n <div class=\"tab-body\">\n <pre class=\"code-block\">\n {{ configJson }}\n </pre>\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <!-- Save | Delete | Cancel, the standard order. fs-form-dialog-actions\n renders its own Save/Done BEFORE the projected content, which would\n put Delete after Cancel, so `[save]=\"false\"` foots the row here and\n all three buttons are projected in order. Save submits the Settings\n tab; the other tabs persist as you touch them, so they get Done. -->\n <fs-form-dialog-actions [save]=\"false\">\n @if (selectedTab === 'settings') {\n <button\n mat-button\n type=\"submit\"\n color=\"primary\">\n Save\n </button>\n } @else {\n <button\n mat-button\n type=\"button\"\n color=\"primary\"\n [mat-dialog-close]=\"null\">\n Done\n </button>\n }\n <button\n mat-button\n type=\"button\"\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n <button\n mat-button\n type=\"button\"\n class=\"cancel-button\"\n [mat-dialog-close]=\"null\">\n Cancel\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}.geometry-row{display:flex;gap:8px}.geometry-row mat-form-field{flex:1}.filter-table{border-spacing:0;width:100%;border-collapse:collapse}.filter-table thead th{color:#8f8f8f;font-weight:400;font-size:85%;text-align:left;padding:8px 16px}.filter-table tbody tr{clip-path:xywh(0 3px 100% calc(100% - 6px) round 10px)}.filter-table tbody td{border:none;background-color:#fafafa;padding:8px 16px;vertical-align:middle;text-align:left}.filter-table .filter-col-name{font-weight:600;color:#1f2933}.filter-table .filter-col-type{color:#7b8794;width:120px}.filter-table .filter-col-toggle,.filter-table th.filter-col-toggle{width:130px;text-align:center}.filters-empty{font-size:13px;color:#7b8794;padding:8px 0 4px}.filters-empty.error{color:#e15759}.code-block{margin:0;padding:10px 12px;background:#1f2933;color:#e4e7eb;border-radius:6px;font-family:JetBrains Mono,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow:auto}\n"] }]
3542
4178
  }] });
3543
4179
 
3544
4180
  // Self-contained timezone picker for the report settings dialog. The IANA zone
3545
4181
  // list comes from the browser (Intl.supportedValuesOf), so the package carries no
3546
- // dependency on any app timezone API. Defaults an empty value to the viewer's zone.
4182
+ // dependency on any app timezone API. It shows the zone it is given and never
4183
+ // picks one itself: the dialog always seeds the zone the report uses, and that
4184
+ // zone is listed even when the browser's list lacks it (some browsers leave
4185
+ // 'UTC' out).
3547
4186
  class TimezoneSelectComponent {
3548
4187
  placeholder = 'Timezone';
3549
4188
  required = false;
3550
4189
  disabled = false;
3551
4190
  timezoneChange = new EventEmitter();
3552
4191
  set timezone(value) {
3553
- if (value && value !== this._timezone) {
3554
- this._timezone = value;
3555
- this.timezoneChange.emit(this.timezone);
4192
+ if (value && value !== this._timezone()) {
4193
+ this._timezone.set(value);
4194
+ this.timezoneChange.emit(value);
3556
4195
  }
3557
4196
  }
3558
4197
  get timezone() {
3559
- return this._timezone;
4198
+ return this._timezone();
3560
4199
  }
3561
4200
  guid = guid();
3562
- timezones = [];
3563
- _browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
3564
- _timezone = null;
3565
- ngOnInit() {
3566
- this.timezones = this._supportedTimezones();
3567
- if (!this.timezone) {
3568
- this.timezone = this._browserTimezone;
3569
- }
3570
- }
3571
- // The browser's IANA zone list where available; fall back to at least the
3572
- // viewer's own zone so the control is never empty.
3573
- _supportedTimezones() {
4201
+ timezones = computed(() => this._supportedTimezones(this._timezone()));
4202
+ _timezone = signal('');
4203
+ // The browser's IANA zone list where available, with the current value
4204
+ // prepended when the list lacks it, so the select always shows the zone the
4205
+ // report uses.
4206
+ _supportedTimezones(current) {
3574
4207
  const intl = Intl;
3575
- return typeof intl.supportedValuesOf === 'function'
4208
+ const zones = typeof intl.supportedValuesOf === 'function'
3576
4209
  ? intl.supportedValuesOf('timeZone')
3577
- : [this._browserTimezone];
4210
+ : [];
4211
+ return current && !zones.includes(current)
4212
+ ? [current, ...zones]
4213
+ : zones;
3578
4214
  }
3579
4215
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TimezoneSelectComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3580
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: TimezoneSelectComponent, isStandalone: true, selector: "fs-ai-report-timezone-select", inputs: { placeholder: "placeholder", required: "required", disabled: "disabled", timezone: "timezone" }, outputs: { timezoneChange: "timezoneChange" }, ngImport: i0, template: "<mat-form-field class=\"full-width\">\n <mat-label>{{placeholder}}</mat-label>\n <mat-select\n [(ngModel)]=\"timezone\"\n [fsFormRequired]=\"required\"\n [disabled]=\"disabled\"\n name=\"timezone_select_{{ guid }}\">\n @for (item of timezones; track item) {\n <mat-option [value]=\"item\">{{ item }}</mat-option>\n }\n </mat-select>\n</mat-form-field>\n", styles: [".full-width{width:100%}\n"], dependencies: [{ kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "component", type: MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$2.FsFormRequiredDirective, selector: "[fsFormRequired],[ngModel][required]", inputs: ["fsFormRequired", "required", "fsFormRequiredMessage"] }], viewProviders: [{ provide: ControlContainer, useExisting: NgForm }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4216
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: TimezoneSelectComponent, isStandalone: true, selector: "fs-ai-report-timezone-select", inputs: { placeholder: "placeholder", required: "required", disabled: "disabled", timezone: "timezone" }, outputs: { timezoneChange: "timezoneChange" }, ngImport: i0, template: "<mat-form-field class=\"full-width\">\n <mat-label>{{placeholder}}</mat-label>\n <mat-select\n [(ngModel)]=\"timezone\"\n [fsFormRequired]=\"required\"\n [disabled]=\"disabled\"\n name=\"timezone_select_{{ guid }}\">\n @for (item of timezones(); track item) {\n <mat-option [value]=\"item\">{{ item }}</mat-option>\n }\n </mat-select>\n</mat-form-field>\n", styles: [".full-width{width:100%}\n"], dependencies: [{ kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "component", type: MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$2.FsFormRequiredDirective, selector: "[fsFormRequired],[ngModel][required]", inputs: ["fsFormRequired", "required", "fsFormRequiredMessage"] }], viewProviders: [{ provide: ControlContainer, useExisting: NgForm }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3581
4217
  }
3582
4218
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TimezoneSelectComponent, decorators: [{
3583
4219
  type: Component,
@@ -3588,7 +4224,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
3588
4224
  MatOption,
3589
4225
  FormsModule,
3590
4226
  FsFormModule,
3591
- ], template: "<mat-form-field class=\"full-width\">\n <mat-label>{{placeholder}}</mat-label>\n <mat-select\n [(ngModel)]=\"timezone\"\n [fsFormRequired]=\"required\"\n [disabled]=\"disabled\"\n name=\"timezone_select_{{ guid }}\">\n @for (item of timezones; track item) {\n <mat-option [value]=\"item\">{{ item }}</mat-option>\n }\n </mat-select>\n</mat-form-field>\n", styles: [".full-width{width:100%}\n"] }]
4227
+ ], template: "<mat-form-field class=\"full-width\">\n <mat-label>{{placeholder}}</mat-label>\n <mat-select\n [(ngModel)]=\"timezone\"\n [fsFormRequired]=\"required\"\n [disabled]=\"disabled\"\n name=\"timezone_select_{{ guid }}\">\n @for (item of timezones(); track item) {\n <mat-option [value]=\"item\">{{ item }}</mat-option>\n }\n </mat-select>\n</mat-form-field>\n", styles: [".full-width{width:100%}\n"] }]
3592
4228
  }], propDecorators: { placeholder: [{
3593
4229
  type: Input
3594
4230
  }], required: [{
@@ -3601,27 +4237,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
3601
4237
  type: Input
3602
4238
  }] } });
3603
4239
 
3604
- // Date-range defaults are stored as calendar-day strings (yyyy-MM-dd, no time,
3605
- // no timezone) so the backend never shifts them; the date picker works in Date
3606
- // objects. These two convert between the two forms — the only translation the
3607
- // Filters tab needs for a fixed date range.
3608
- // A stored yyyy-MM-dd boundary as a local Date for the picker, or null.
3609
- function dateFromBound(value) {
3610
- if (!value) {
3611
- return null;
3612
- }
3613
- const date = parseISO(value.slice(0, 10));
3614
- return isNaN(date.getTime()) ? null : date;
3615
- }
3616
- // A picked Date as a yyyy-MM-dd boundary for storage, or null. Uses the local
3617
- // calendar day (format, not toISOString) so the boundary is the day the admin
3618
- // picked, regardless of timezone.
3619
- function dateToBound(value) {
3620
- return value instanceof Date && !isNaN(value.getTime())
3621
- ? format(value, 'yyyy-MM-dd')
3622
- : null;
3623
- }
3624
-
3625
4240
  // Report settings, in three tabs:
3626
4241
  // Settings — name + page setup (size in inches, header/footer band depths,
3627
4242
  // layout mode).
@@ -3652,12 +4267,15 @@ class ReportSettingsComponent {
3652
4267
  footerHeight = 0;
3653
4268
  headerFooterMax = HEADER_FOOTER_MAX;
3654
4269
  layout = 'freeform';
3655
- // Empty until the report has one; app-timezone-select then defaults it to the
3656
- // viewer's browser zone (which Save persists, making the report's zone explicit).
4270
+ // Seeded with the zone the report actually uses: its own stored zone, else
4271
+ // the application default the server resolved (effectiveTimezone). Save sends
4272
+ // it only when it differs from that effective zone, so opening and saving a
4273
+ // report that follows the default never pins it to a zone.
3657
4274
  timezone = '';
3658
4275
  headingSize = DEFAULT_HEADING_SIZE;
3659
4276
  // The report-level filters and their editable default values (Filters tab).
3660
4277
  filterRows = signal([]);
4278
+ relativeLabel = relativeLabel;
3661
4279
  _data = inject(MAT_DIALOG_DATA);
3662
4280
  _dialogRef = inject(MatDialogRef);
3663
4281
  _reportData = inject(ReportData);
@@ -3672,7 +4290,7 @@ class ReportSettingsComponent {
3672
4290
  this.headerHeight = report.config?.headerFooter?.headerHeight ?? 0;
3673
4291
  this.footerHeight = report.config?.headerFooter?.footerHeight ?? 0;
3674
4292
  this.layout = report.layout ?? 'freeform';
3675
- this.timezone = report.timezone ?? '';
4293
+ this.timezone = report.timezone ?? report.effectiveTimezone ?? '';
3676
4294
  this.headingSize = report.config?.styles?.heading?.size ?? DEFAULT_HEADING_SIZE;
3677
4295
  this._buildFilterRows(report);
3678
4296
  }
@@ -3700,7 +4318,7 @@ class ReportSettingsComponent {
3700
4318
  pageWidth: this._pageDimension(this.pageWidth),
3701
4319
  pageHeight: this._pageDimension(this.pageHeight),
3702
4320
  layout: this.layout,
3703
- timezone: this.timezone,
4321
+ ...this._timezoneSetting(),
3704
4322
  styles: { heading: { size: this.headingSize } },
3705
4323
  headerFooter: {
3706
4324
  headerHeight: this._pageDimension(this.headerHeight) || 0,
@@ -3713,11 +4331,17 @@ class ReportSettingsComponent {
3713
4331
  default: this._rowDefault(row),
3714
4332
  }));
3715
4333
  return forkJoin([settings$, ...defaults$])
3716
- .pipe(tap(() => {
4334
+ .pipe(tap$1(() => {
3717
4335
  this._message.success('Report settings saved');
3718
4336
  this._dialogRef.close({ action: 'saved' });
3719
4337
  }));
3720
4338
  };
4339
+ // Drop the row's stored rolling preset (Clear). A new row object rather than
4340
+ // a mutation, so the signal sees the change; the @for tracks group.id, so
4341
+ // the row's bindings survive the swap.
4342
+ clearRelative(row) {
4343
+ this.filterRows.update((rows) => rows.map((item) => (item === row ? { ...item, relative: null } : item)));
4344
+ }
3721
4345
  // The page dimension as a real number for the wire. fsMaskNumber hands back
3722
4346
  // the unmasked string it built with a fixed '.' radix (which is the whole
3723
4347
  // point of the mask - a native number input paints the viewer's locale
@@ -3727,6 +4351,16 @@ class ReportSettingsComponent {
3727
4351
  _pageDimension(value) {
3728
4352
  return typeof value === 'number' ? value : parseFloat(value);
3729
4353
  }
4354
+ // The zone for the settings PUT, sent only when it moves the report off the
4355
+ // zone it already uses, so an untouched dialog never pins a report that
4356
+ // follows the application default. A server too old to send
4357
+ // effectiveTimezone gets the zone every time, as it always did.
4358
+ _timezoneSetting() {
4359
+ const effectiveTimezone = this._data.report.effectiveTimezone;
4360
+ return effectiveTimezone === undefined || this.timezone !== effectiveTimezone
4361
+ ? { timezone: this.timezone }
4362
+ : {};
4363
+ }
3730
4364
  // Build one editable row per report-level filter group (level report or both),
3731
4365
  // in bar order, seeding each draft from the group's stored default.
3732
4366
  _buildFilterRows(report) {
@@ -3744,6 +4378,7 @@ class ReportSettingsComponent {
3744
4378
  label: group.label || group.filters?.[0]?.filterColumn || 'Filter',
3745
4379
  start: dateFromBound(groupDefault?.start),
3746
4380
  end: dateFromBound(groupDefault?.end),
4381
+ relative: groupDefault?.relative ?? null,
3747
4382
  // Named below, once the option list carrying their labels is in.
3748
4383
  values: [],
3749
4384
  value: groupDefault?.value ?? '',
@@ -3772,13 +4407,18 @@ class ReportSettingsComponent {
3772
4407
  });
3773
4408
  }
3774
4409
  // The draft default normalized to the wire shape for this row's type, or null
3775
- // when empty (which clears the stored default).
4410
+ // when empty (which clears the stored default). A date range is the picked
4411
+ // dates when there are any, else the carried rolling preset, never both: the
4412
+ // server refuses a relative preset combined with dates.
3776
4413
  _rowDefault(row) {
3777
4414
  switch (row.group.type) {
3778
4415
  case 'dateRange': {
3779
4416
  const start = dateToBound(row.start);
3780
4417
  const end = dateToBound(row.end);
3781
- return (start || end) ? { start: start ?? undefined, end: end ?? undefined } : null;
4418
+ if (start || end) {
4419
+ return { start: start ?? undefined, end: end ?? undefined };
4420
+ }
4421
+ return row.relative ? { relative: row.relative } : null;
3782
4422
  }
3783
4423
  case 'select':
3784
4424
  return row.values.length ? { values: row.values.map((option) => option.value) } : null;
@@ -3796,8 +4436,9 @@ class ReportSettingsComponent {
3796
4436
  const after = JSON.stringify(this._normalize(this._rowDefault(row)));
3797
4437
  return before !== after;
3798
4438
  }
3799
- // A stable comparison form: drop relative (not authored here) and undefined
3800
- // keys so a no-op edit doesn't read as a change.
4439
+ // A stable comparison form: empty and undefined keys are dropped so a no-op
4440
+ // edit doesn't read as a change. relative is compared like the rest: the row
4441
+ // carries it, so an untouched rolling default reads as unchanged.
3801
4442
  _normalize(value) {
3802
4443
  if (!value) {
3803
4444
  return null;
@@ -3821,7 +4462,7 @@ class ReportSettingsComponent {
3821
4462
  return Object.keys(normalized).length ? normalized : null;
3822
4463
  }
3823
4464
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportSettingsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3824
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportSettingsComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n Report Settings\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Name\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"name\"\n name=\"name\"\n [fsFormRequired]=\"true\">\n </mat-form-field>\n <!--\n The page, typed in inches. Orientation is not asked for because it\n is not an independent choice - a page wider than it is tall IS\n landscape - so the two numbers say everything there is to say.\n -->\n <div class=\"fs-row.gap-sm.align-start\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Width\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageWidth\"\n name=\"pageWidth\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageHeight\"\n name=\"pageHeight\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n </div>\n <!--\n How deep the header and footer bands are. Their content is edited\n on the canvas (Edit layout \u2192 Header & footer); these reserve the\n space for it, and 0 means no band.\n -->\n <mat-form-field class=\"full-width\">\n <mat-label>\n Header height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"headerHeight\"\n name=\"headerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"full-width\">\n <mat-label>\n Footer height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"footerHeight\"\n name=\"footerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Layout\n </mat-label>\n <mat-select\n [(ngModel)]=\"layout\"\n name=\"layout\">\n <mat-option value=\"freeform\">\n Freeform \u2014 position components anywhere on the page\n </mat-option>\n <mat-option value=\"flow\">\n Flow \u2014 components flow into rows by width %\n </mat-option>\n </mat-select>\n <mat-hint>\n Freeform is like PowerPoint; Flow is like a responsive dashboard.\n </mat-hint>\n </mat-form-field>\n <fs-ai-report-timezone-select [(timezone)]=\"timezone\"></fs-ai-report-timezone-select>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Styles\"\n name=\"styles\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Heading Size\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"1\"\n min=\"6\"\n max=\"96\"\n [(ngModel)]=\"headingSize\"\n name=\"headingSize\"\n [fsFormMin]=\"6\"\n [fsFormMax]=\"96\">\n <span matTextSuffix>\n pt\n </span>\n <mat-hint>\n Applies to component titles. Points map 1:1 to PDF and PowerPoint.\n </mat-hint>\n </mat-form-field>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"fs-column tab-body\">\n @if (filterRows().length) {\n @for (row of filterRows(); track row.group.id) {\n @switch (row.group.type) {\n @case ('dateRange') {\n <div class=\"fs-row.gap-sm.align-center fs-flex\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default From {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.start\"\n [name]=\"'start' + row.group.id\">\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default To {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.end\"\n [name]=\"'end' + row.group.id\">\n </mat-form-field>\n </div>\n }\n @case ('select') {\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [label]=\"'Default ' + row.label\"\n [fetch]=\"row.fetch\"\n [(ngModel)]=\"row.values\"\n [name]=\"'values' + row.group.id\"\n [multiple]=\"true\"\n [fetchOnFocus]=\"true\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n </fs-autocomplete-chips>\n }\n @default {\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default {{ row.label }}\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"row.value\"\n [name]=\"'value' + row.group.id\">\n </mat-form-field>\n }\n }\n }\n } @else {\n <fs-message-info>\n This report has no report-level filters yet. Add a filter to a\n component (its settings \u2192 Filters) and show it in the report bar,\n then set its default here.\n </fs-message-info>\n }\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <fs-form-dialog-actions>\n <button\n type=\"button\"\n mat-button\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1$1.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$2.FsFormDirective, selector: "[fsForm]", inputs: ["wrapperSelector", "messageSelector", "hintSelector", "labelSelector", "autocomplete", "shortcuts", "confirm", "confirmDialog", "confirmDrawer", "confirmBrowser", "dirtySubmitButton", "submit", "successDelay", "errorDelay", "deactivationGuard"], outputs: ["fsForm", "invalid", "valid", "submitted", "reseted", "cleared"], exportAs: ["fsForm"] }, { kind: "directive", type: i2$2.FsFormRequiredDirective, selector: "[fsFormRequired],[ngModel][required]", inputs: ["fsFormRequired", "required", "fsFormRequiredMessage"] }, { kind: "directive", type: i2$2.FsFormMinDirective, selector: "[fsFormMin]", inputs: ["fsFormMin", "fsFormMinMessage"] }, { kind: "directive", type: i2$2.FsFormMaxDirective, selector: "[fsFormMax]", inputs: ["fsFormMax", "fsFormMaxMessage"] }, { kind: "component", type: i2$2.FsFormDialogActionsComponent, selector: "fs-form-dialog-actions", inputs: ["save", "create", "close", "done", "closeData", "name"] }, { kind: "directive", type: i2$2.FsFormNoFsValidatorsDirective, selector: "[ngModel]:not([required]):not([fsFormRequired]):not([fsFormCompare]):not([fsFormDateRange]):not([fsFormEmail]):not([fsFormEmails]):not([fsFormFunction]):not([fsFormGreater]):not([fsFormGreaterEqual]):not([fsFormInteger]):not([fsFormLesser]):not([fsFormMax]):not([fsFormMaxLength]):not([fsFormMin]):not([fsFormMinLength]):not([fsFormNumeric]):not([fsFormPattern]):not([fsFormPhone]):not([fsFormUrl]):not([validate])" }, { kind: "directive", type: i2$2.FsButtonDirective, selector: "[mat-raised-button],[mat-button],[mat-flat-button],[mat-stroked-button]", inputs: ["name", "dirtySubmit"] }, { kind: "ngmodule", type: FsMaskModule }, { kind: "directive", type: i3$1.FsMaskNumberDirective, selector: "[fsMaskNumber]" }, { kind: "ngmodule", type: FsDialogModule }, { kind: "component", type: i3$2.FsDialogComponent, selector: "fs-dialog", inputs: ["mobileMode", "mobileButtonPlacement", "mobileWidth", "mode", "buttonLayout"] }, { kind: "ngmodule", type: FsMessageModule }, { kind: "component", type: i5$1.FsMessageInfoComponent, selector: "fs-message-info" }, { kind: "ngmodule", type: FsTabsModule }, { kind: "directive", type: i5.FsTabsHeaderTabGroupDirective, selector: "mat-tab-group, matTabGroup, [matTabGroup]", inputs: ["orientation", "selected", "selectedData"], outputs: ["selectedChange", "selectedDataChange"], exportAs: ["fsTabsHeaderTabGroup"] }, { kind: "directive", type: i5.FsTabsTabDirective, selector: "mat-tab,matTab", inputs: ["name", "data"], exportAs: ["fsTabsTab"] }, { kind: "ngmodule", type: FsDatePickerModule }, { kind: "component", type: i7.FsDatePickerComponent, selector: "[fsDatePicker]", inputs: ["minYear", "maxYear", "minDate", "maxDate", "startOfDay", "view", "format", "minutes", "width"], outputs: ["change"] }, { kind: "ngmodule", type: FsAutocompleteChipsModule }, { kind: "component", type: i2$3.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$3.FsAutocompleteObjectDirective, selector: "[fsAutocompleteObject],[fsAutocompleteChipsTemplate]" }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i6.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass"], exportAs: ["matTab"] }, { kind: "component", type: i6.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "directive", type: MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "directive", type: MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "directive", type: MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly"], exportAs: ["matInput"] }, { kind: "component", type: MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: MatOption$1, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "component", type: TimezoneSelectComponent, selector: "fs-ai-report-timezone-select", inputs: ["placeholder", "required", "disabled", "timezone"], outputs: ["timezoneChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4465
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportSettingsComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n Report Settings\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Name\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"name\"\n name=\"name\"\n [fsFormRequired]=\"true\">\n </mat-form-field>\n <!--\n The page, typed in inches. Orientation is not asked for because it\n is not an independent choice - a page wider than it is tall IS\n landscape - so the two numbers say everything there is to say.\n -->\n <div class=\"fs-row.gap-sm.align-start\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Width\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageWidth\"\n name=\"pageWidth\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageHeight\"\n name=\"pageHeight\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n </div>\n <!--\n How deep the header and footer bands are. Their content is edited\n on the canvas (Edit layout \u2192 Header & footer); these reserve the\n space for it, and 0 means no band.\n -->\n <mat-form-field class=\"full-width\">\n <mat-label>\n Header height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"headerHeight\"\n name=\"headerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"full-width\">\n <mat-label>\n Footer height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"footerHeight\"\n name=\"footerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Layout\n </mat-label>\n <mat-select\n [(ngModel)]=\"layout\"\n name=\"layout\">\n <mat-option value=\"freeform\">\n Freeform \u2014 position components anywhere on the page\n </mat-option>\n <mat-option value=\"flow\">\n Flow \u2014 components flow into rows by width %\n </mat-option>\n </mat-select>\n <mat-hint>\n Freeform is like PowerPoint; Flow is like a responsive dashboard.\n </mat-hint>\n </mat-form-field>\n <fs-ai-report-timezone-select [(timezone)]=\"timezone\"></fs-ai-report-timezone-select>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Styles\"\n name=\"styles\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Heading Size\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"1\"\n min=\"6\"\n max=\"96\"\n [(ngModel)]=\"headingSize\"\n name=\"headingSize\"\n [fsFormMin]=\"6\"\n [fsFormMax]=\"96\">\n <span matTextSuffix>\n pt\n </span>\n <mat-hint>\n Applies to component titles. Points map 1:1 to PDF and PowerPoint.\n </mat-hint>\n </mat-form-field>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"fs-column tab-body\">\n @if (filterRows().length) {\n @for (row of filterRows(); track row.group.id) {\n @switch (row.group.type) {\n @case ('dateRange') {\n <div class=\"fs-row.gap-sm.align-center fs-flex\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default From {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.start\"\n [name]=\"'start' + row.group.id\">\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default To {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.end\"\n [name]=\"'end' + row.group.id\">\n </mat-form-field>\n </div>\n <!-- A stored rolling preset cannot be authored here, only\n kept, replaced by picking dates, or cleared. type=\"button\"\n keeps Clear from submitting the form. -->\n @if (row.relative && !row.start && !row.end) {\n <div class=\"fs-row.gap-sm.align-center\">\n <mat-hint class=\"fs-flex\">\n Rolling default: {{ relativeLabel(row.relative) }}. Pick dates to replace it.\n </mat-hint>\n <button\n mat-button\n type=\"button\"\n (click)=\"clearRelative(row)\">\n Clear\n </button>\n </div>\n }\n }\n @case ('select') {\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [label]=\"'Default ' + row.label\"\n [fetch]=\"row.fetch\"\n [(ngModel)]=\"row.values\"\n [name]=\"'values' + row.group.id\"\n [multiple]=\"true\"\n [fetchOnFocus]=\"true\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n </fs-autocomplete-chips>\n }\n @default {\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default {{ row.label }}\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"row.value\"\n [name]=\"'value' + row.group.id\">\n </mat-form-field>\n }\n }\n }\n } @else {\n <fs-message-info>\n This report has no report-level filters yet. Add a filter to a\n component (its settings \u2192 Filters) and show it in the report bar,\n then set its default here.\n </fs-message-info>\n }\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <fs-form-dialog-actions>\n <button\n type=\"button\"\n mat-button\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1$1.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$2.FsFormDirective, selector: "[fsForm]", inputs: ["wrapperSelector", "messageSelector", "hintSelector", "labelSelector", "autocomplete", "shortcuts", "confirm", "confirmDialog", "confirmDrawer", "confirmBrowser", "dirtySubmitButton", "submit", "successDelay", "errorDelay", "deactivationGuard"], outputs: ["fsForm", "invalid", "valid", "submitted", "reseted", "cleared"], exportAs: ["fsForm"] }, { kind: "directive", type: i2$2.FsFormRequiredDirective, selector: "[fsFormRequired],[ngModel][required]", inputs: ["fsFormRequired", "required", "fsFormRequiredMessage"] }, { kind: "directive", type: i2$2.FsFormMinDirective, selector: "[fsFormMin]", inputs: ["fsFormMin", "fsFormMinMessage"] }, { kind: "directive", type: i2$2.FsFormMaxDirective, selector: "[fsFormMax]", inputs: ["fsFormMax", "fsFormMaxMessage"] }, { kind: "component", type: i2$2.FsFormDialogActionsComponent, selector: "fs-form-dialog-actions", inputs: ["save", "create", "close", "done", "closeData", "name"] }, { kind: "directive", type: i2$2.FsFormNoFsValidatorsDirective, selector: "[ngModel]:not([required]):not([fsFormRequired]):not([fsFormCompare]):not([fsFormDateRange]):not([fsFormEmail]):not([fsFormEmails]):not([fsFormFunction]):not([fsFormGreater]):not([fsFormGreaterEqual]):not([fsFormInteger]):not([fsFormLesser]):not([fsFormMax]):not([fsFormMaxLength]):not([fsFormMin]):not([fsFormMinLength]):not([fsFormNumeric]):not([fsFormPattern]):not([fsFormPhone]):not([fsFormUrl]):not([validate])" }, { kind: "directive", type: i2$2.FsButtonDirective, selector: "[mat-raised-button],[mat-button],[mat-flat-button],[mat-stroked-button]", inputs: ["name", "dirtySubmit"] }, { kind: "ngmodule", type: FsMaskModule }, { kind: "directive", type: i3$1.FsMaskNumberDirective, selector: "[fsMaskNumber]" }, { kind: "ngmodule", type: FsDialogModule }, { kind: "component", type: i3$2.FsDialogComponent, selector: "fs-dialog", inputs: ["mobileMode", "mobileButtonPlacement", "mobileWidth", "mode", "buttonLayout"] }, { kind: "ngmodule", type: FsMessageModule }, { kind: "component", type: i5$1.FsMessageInfoComponent, selector: "fs-message-info" }, { kind: "ngmodule", type: FsTabsModule }, { kind: "directive", type: i5.FsTabsHeaderTabGroupDirective, selector: "mat-tab-group, matTabGroup, [matTabGroup]", inputs: ["orientation", "selected", "selectedData"], outputs: ["selectedChange", "selectedDataChange"], exportAs: ["fsTabsHeaderTabGroup"] }, { kind: "directive", type: i5.FsTabsTabDirective, selector: "mat-tab,matTab", inputs: ["name", "data"], exportAs: ["fsTabsTab"] }, { kind: "ngmodule", type: FsDatePickerModule }, { kind: "component", type: i7.FsDatePickerComponent, selector: "[fsDatePicker]", inputs: ["minYear", "maxYear", "minDate", "maxDate", "startOfDay", "view", "format", "minutes", "width"], outputs: ["change"] }, { kind: "ngmodule", type: FsAutocompleteChipsModule }, { kind: "component", type: i2$3.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$3.FsAutocompleteObjectDirective, selector: "[fsAutocompleteObject],[fsAutocompleteChipsTemplate]" }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i6.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass"], exportAs: ["matTab"] }, { kind: "component", type: i6.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "directive", type: MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "directive", type: MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "directive", type: MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly"], exportAs: ["matInput"] }, { kind: "component", type: MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: MatOption$1, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "component", type: TimezoneSelectComponent, selector: "fs-ai-report-timezone-select", inputs: ["placeholder", "required", "disabled", "timezone"], outputs: ["timezoneChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3825
4466
  }
3826
4467
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportSettingsComponent, decorators: [{
3827
4468
  type: Component,
@@ -3847,12 +4488,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
3847
4488
  MatSelect,
3848
4489
  MatOption$1,
3849
4490
  TimezoneSelectComponent,
3850
- ], template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n Report Settings\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Name\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"name\"\n name=\"name\"\n [fsFormRequired]=\"true\">\n </mat-form-field>\n <!--\n The page, typed in inches. Orientation is not asked for because it\n is not an independent choice - a page wider than it is tall IS\n landscape - so the two numbers say everything there is to say.\n -->\n <div class=\"fs-row.gap-sm.align-start\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Width\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageWidth\"\n name=\"pageWidth\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageHeight\"\n name=\"pageHeight\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n </div>\n <!--\n How deep the header and footer bands are. Their content is edited\n on the canvas (Edit layout \u2192 Header & footer); these reserve the\n space for it, and 0 means no band.\n -->\n <mat-form-field class=\"full-width\">\n <mat-label>\n Header height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"headerHeight\"\n name=\"headerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"full-width\">\n <mat-label>\n Footer height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"footerHeight\"\n name=\"footerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Layout\n </mat-label>\n <mat-select\n [(ngModel)]=\"layout\"\n name=\"layout\">\n <mat-option value=\"freeform\">\n Freeform \u2014 position components anywhere on the page\n </mat-option>\n <mat-option value=\"flow\">\n Flow \u2014 components flow into rows by width %\n </mat-option>\n </mat-select>\n <mat-hint>\n Freeform is like PowerPoint; Flow is like a responsive dashboard.\n </mat-hint>\n </mat-form-field>\n <fs-ai-report-timezone-select [(timezone)]=\"timezone\"></fs-ai-report-timezone-select>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Styles\"\n name=\"styles\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Heading Size\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"1\"\n min=\"6\"\n max=\"96\"\n [(ngModel)]=\"headingSize\"\n name=\"headingSize\"\n [fsFormMin]=\"6\"\n [fsFormMax]=\"96\">\n <span matTextSuffix>\n pt\n </span>\n <mat-hint>\n Applies to component titles. Points map 1:1 to PDF and PowerPoint.\n </mat-hint>\n </mat-form-field>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"fs-column tab-body\">\n @if (filterRows().length) {\n @for (row of filterRows(); track row.group.id) {\n @switch (row.group.type) {\n @case ('dateRange') {\n <div class=\"fs-row.gap-sm.align-center fs-flex\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default From {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.start\"\n [name]=\"'start' + row.group.id\">\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default To {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.end\"\n [name]=\"'end' + row.group.id\">\n </mat-form-field>\n </div>\n }\n @case ('select') {\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [label]=\"'Default ' + row.label\"\n [fetch]=\"row.fetch\"\n [(ngModel)]=\"row.values\"\n [name]=\"'values' + row.group.id\"\n [multiple]=\"true\"\n [fetchOnFocus]=\"true\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n </fs-autocomplete-chips>\n }\n @default {\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default {{ row.label }}\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"row.value\"\n [name]=\"'value' + row.group.id\">\n </mat-form-field>\n }\n }\n }\n } @else {\n <fs-message-info>\n This report has no report-level filters yet. Add a filter to a\n component (its settings \u2192 Filters) and show it in the report bar,\n then set its default here.\n </fs-message-info>\n }\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <fs-form-dialog-actions>\n <button\n type=\"button\"\n mat-button\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}\n"] }]
4491
+ ], template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n Report Settings\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Name\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"name\"\n name=\"name\"\n [fsFormRequired]=\"true\">\n </mat-form-field>\n <!--\n The page, typed in inches. Orientation is not asked for because it\n is not an independent choice - a page wider than it is tall IS\n landscape - so the two numbers say everything there is to say.\n -->\n <div class=\"fs-row.gap-sm.align-start\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Width\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageWidth\"\n name=\"pageWidth\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"1\"\n [(ngModel)]=\"pageHeight\"\n name=\"pageHeight\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n </div>\n <!--\n How deep the header and footer bands are. Their content is edited\n on the canvas (Edit layout \u2192 Header & footer); these reserve the\n space for it, and 0 means no band.\n -->\n <mat-form-field class=\"full-width\">\n <mat-label>\n Header height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"headerHeight\"\n name=\"headerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field class=\"full-width\">\n <mat-label>\n Footer height\n </mat-label>\n <input\n matInput\n fsMaskNumber\n [scale]=\"2\"\n [(ngModel)]=\"footerHeight\"\n name=\"footerHeight\"\n [fsFormMin]=\"0\"\n [fsFormMax]=\"headerFooterMax\">\n <div matTextSuffix>\n inches\n </div>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Layout\n </mat-label>\n <mat-select\n [(ngModel)]=\"layout\"\n name=\"layout\">\n <mat-option value=\"freeform\">\n Freeform \u2014 position components anywhere on the page\n </mat-option>\n <mat-option value=\"flow\">\n Flow \u2014 components flow into rows by width %\n </mat-option>\n </mat-select>\n <mat-hint>\n Freeform is like PowerPoint; Flow is like a responsive dashboard.\n </mat-hint>\n </mat-form-field>\n <fs-ai-report-timezone-select [(timezone)]=\"timezone\"></fs-ai-report-timezone-select>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Styles\"\n name=\"styles\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Heading Size\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"1\"\n min=\"6\"\n max=\"96\"\n [(ngModel)]=\"headingSize\"\n name=\"headingSize\"\n [fsFormMin]=\"6\"\n [fsFormMax]=\"96\">\n <span matTextSuffix>\n pt\n </span>\n <mat-hint>\n Applies to component titles. Points map 1:1 to PDF and PowerPoint.\n </mat-hint>\n </mat-form-field>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"fs-column tab-body\">\n @if (filterRows().length) {\n @for (row of filterRows(); track row.group.id) {\n @switch (row.group.type) {\n @case ('dateRange') {\n <div class=\"fs-row.gap-sm.align-center fs-flex\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default From {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.start\"\n [name]=\"'start' + row.group.id\">\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default To {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.end\"\n [name]=\"'end' + row.group.id\">\n </mat-form-field>\n </div>\n <!-- A stored rolling preset cannot be authored here, only\n kept, replaced by picking dates, or cleared. type=\"button\"\n keeps Clear from submitting the form. -->\n @if (row.relative && !row.start && !row.end) {\n <div class=\"fs-row.gap-sm.align-center\">\n <mat-hint class=\"fs-flex\">\n Rolling default: {{ relativeLabel(row.relative) }}. Pick dates to replace it.\n </mat-hint>\n <button\n mat-button\n type=\"button\"\n (click)=\"clearRelative(row)\">\n Clear\n </button>\n </div>\n }\n }\n @case ('select') {\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [label]=\"'Default ' + row.label\"\n [fetch]=\"row.fetch\"\n [(ngModel)]=\"row.values\"\n [name]=\"'values' + row.group.id\"\n [multiple]=\"true\"\n [fetchOnFocus]=\"true\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n </fs-autocomplete-chips>\n }\n @default {\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default {{ row.label }}\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"row.value\"\n [name]=\"'value' + row.group.id\">\n </mat-form-field>\n }\n }\n }\n } @else {\n <fs-message-info>\n This report has no report-level filters yet. Add a filter to a\n component (its settings \u2192 Filters) and show it in the report bar,\n then set its default here.\n </fs-message-info>\n }\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <fs-form-dialog-actions>\n <button\n type=\"button\"\n mat-button\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}\n"] }]
3851
4492
  }] });
3852
4493
 
3853
- // Rows fetched for a list component's export table — enough to fill a
3854
- // slide/page table; the cap note covers the rest.
3855
- const LIST_EXPORT_ROWS = 100;
3856
4494
  // How many tile queries an export runs at once.
3857
4495
  //
3858
4496
  // Not a politeness setting — it is the difference between a deck and a broken
@@ -3879,6 +4517,55 @@ function emptyLabel(item) {
3879
4517
  function isFailed(item) {
3880
4518
  return !item.data && item.component.type !== 'text';
3881
4519
  }
4520
+ // The lines under the report's name on the PDF title page and the title slide:
4521
+ // the export date, the active filters, then what the numbers are measured in.
4522
+ // The date is today in the report's zone, the same day its filters resolve
4523
+ // "today" to.
4524
+ function coverLines(collected) {
4525
+ return [
4526
+ format(getZonedNow(resolveReportTimezone(collected.report)), 'MMMM d, yyyy'),
4527
+ ...collected.filterSummary,
4528
+ ...collected.contextLines,
4529
+ ];
4530
+ }
4531
+ // An exported list cell, reading like the on-screen cell: the SQL already
4532
+ // carries the formatting, so the value goes out verbatim. A date column is the
4533
+ // one value the browser renders, and it renders through the same function as
4534
+ // the screen, in the report's zone.
4535
+ function exportCell(value, columnFormat, timezone) {
4536
+ if (value === null || value === undefined) {
4537
+ return '';
4538
+ }
4539
+ return columnFormat === 'date' ? formatReportDate(value, timezone) : String(value);
4540
+ }
4541
+ // The columns an exported list table draws. A cross-tab's columns arrive with
4542
+ // its rows (the row key first, then one per pivot value), since only the
4543
+ // result knows the pivot values; a plain list draws its configured columns,
4544
+ // else every column the query returned.
4545
+ function listColumns(component, data) {
4546
+ const config = (component.config ?? {});
4547
+ if (!config.pivotBy?.column && config.columns?.length) {
4548
+ return config.columns;
4549
+ }
4550
+ return data.columns.map((column) => ({ column }));
4551
+ }
4552
+ // The heading over each column of listColumns(). A cross-tab's first column
4553
+ // is headed by rowBy.label, else its column name, and every other heading is
4554
+ // the raw pivot value; a plain list's columns keep their configured labels.
4555
+ function listHeadings(component, data) {
4556
+ const config = (component.config ?? {});
4557
+ if (config.pivotBy?.column) {
4558
+ return data.columns.map((column, index) => (index === 0 ? config.rowBy?.label || column : column));
4559
+ }
4560
+ return listColumns(component, data).map((column) => column.label || column.column);
4561
+ }
4562
+ // The last row of an exported list table that stops short of its result: how
4563
+ // many rows the file leaves out and where the whole set can be had. Each
4564
+ // format fetches its own number of rows (PDF_LIST_ROWS, PPTX_LIST_ROWS), and
4565
+ // both name what is left with this one sentence.
4566
+ function moreRowsNotice(remaining) {
4567
+ return `… ${remaining} more row(s) — use the component's CSV export for the full dataset`;
4568
+ }
3882
4569
  // The pages of a collected report that have at least one tile whose query
3883
4570
  // failed — what the export gate names to the user, and what makes "some of this
3884
4571
  // file is missing" a fact the caller can act on rather than a thing they notice
@@ -3901,7 +4588,9 @@ function failedPageNames(collected) {
3901
4588
  class ReportExportCollectorService {
3902
4589
  _reportData = inject(ReportData);
3903
4590
  _filterState = inject(ReportFilterStateService);
3904
- collect(report) {
4591
+ // listRows is how many rows each list fetches: the renderer's own cap, since
4592
+ // a PDF table continues onto extra pages and a slide table cannot.
4593
+ collect(report, listRows) {
3905
4594
  const slots = [];
3906
4595
  report.pages.forEach((page, pageIndex) => {
3907
4596
  for (const component of page.components) {
@@ -3917,12 +4606,12 @@ class ReportExportCollectorService {
3917
4606
  // happened to finish first.
3918
4607
  const all$ = slots.length
3919
4608
  ? from(slots.map((slot, index) => ({ slot, index })))
3920
- .pipe(mergeMap(({ slot, index }) => this._componentData(report, slot.component)
4609
+ .pipe(mergeMap(({ slot, index }) => this._componentData(report, slot.component, listRows)
3921
4610
  .pipe(map$1((item) => ({ item, index }))), EXPORT_CONCURRENCY), toArray())
3922
4611
  : of([]);
3923
4612
  // The summary rides alongside the component data rather than being built at
3924
4613
  // the end, because naming a select filter's values is itself a request.
3925
- return forkJoin({ collected: all$, filterSummary: this._filterSummary(report) })
4614
+ return forkJoin({ collected: all$, filterSummary: this.getFilterSummary(report) })
3926
4615
  .pipe(map$1(({ collected, filterSummary }) => {
3927
4616
  const pages = report.pages.map((page) => ({ page, components: [] }));
3928
4617
  [...collected]
@@ -3934,34 +4623,19 @@ class ReportExportCollectorService {
3934
4623
  report,
3935
4624
  pages,
3936
4625
  filterSummary,
4626
+ contextLines: this._contextLines(report),
3937
4627
  };
3938
4628
  }));
3939
4629
  }
3940
- _componentData(report, component) {
3941
- if (component.type === 'text') {
3942
- return of({ component, data: null });
3943
- }
3944
- const filters = this._filterState.resolveForComponent(component);
3945
- // A time-series chart carries the viewer's group-by grain so the export
3946
- // re-buckets exactly as the screen does; lists carry their export paging.
3947
- const frequency = this._filterState.frequency();
3948
- const isTimeSeries = component.type === 'chart' && component.config?.xAxis?.kind === 'time';
3949
- const state = component.type === 'list'
3950
- ? { page: 1, pageSize: LIST_EXPORT_ROWS }
3951
- : (isTimeSeries && frequency ? { frequency } : {});
3952
- // handlers: false keeps the app's global error dialog out of this. A failed
3953
- // tile is reported once, by the export gate, naming the pages it cost —
3954
- // never as ten stacked modals carrying the database's own wording.
3955
- return this._reportData.componentData(report.id, component.id, filters, state, true, { handlers: false })
3956
- .pipe(map$1((data) => ({ component, data })), catchError(() => of({ component, data: null })));
3957
- }
3958
- // "Report Period: Jun 12, 2025 – Jun 12, 2026", "Organization: Alnylam - AHP,
3959
- // Alnylam - PH1" — one line per filter group that has an active session value.
4630
+ // "Report Period: Jun 12, 2025 – Jun 12, 2026", "Region: North, East" — one
4631
+ // line per filter group that has an active session value, then the group-by
4632
+ // line. The filter bar as the viewer reads it: the PDF and PowerPoint covers
4633
+ // print it, and the report zip export sends it for its notes file.
3960
4634
  //
3961
4635
  // A select group stores ids, so its line has to be named off the option list
3962
4636
  // before it can be printed; a date or keyword group already holds its own
3963
4637
  // display value and resolves synchronously.
3964
- _filterSummary(report) {
4638
+ getFilterSummary(report) {
3965
4639
  const lines = [];
3966
4640
  for (const group of report.filterGroups ?? []) {
3967
4641
  const value = this._filterState.value(group.id);
@@ -3990,6 +4664,35 @@ class ReportExportCollectorService {
3990
4664
  }
3991
4665
  return lines.length ? forkJoin(lines) : of([]);
3992
4666
  }
4667
+ _componentData(report, component, listRows) {
4668
+ if (component.type === 'text') {
4669
+ return of({ component, data: null });
4670
+ }
4671
+ const filters = this._filterState.resolveForComponent(component);
4672
+ // A time-series chart carries the viewer's group-by grain so the export
4673
+ // re-buckets exactly as the screen does; lists carry their export paging
4674
+ // and the viewer's header sort, so the table reads in the screen's order.
4675
+ //
4676
+ // The time-series check deliberately mirrors the server's canonical
4677
+ // ReportQueryRunner::_isTimeSeriesChart, so a product pinned to an older
4678
+ // framework does not re-bucket a non-time chart.
4679
+ const frequency = this._filterState.frequency();
4680
+ const isTimeSeries = component.type === 'chart' && component.config?.xAxis?.kind === 'time';
4681
+ const state = component.type === 'list'
4682
+ ? { page: 1, pageSize: listRows, sort: this._filterState.listSort(component.id) ?? undefined }
4683
+ : (isTimeSeries && frequency ? { frequency } : {});
4684
+ // handlers: false keeps the app's global error dialog out of this. A failed
4685
+ // tile is reported once, by the export gate, naming the pages it cost —
4686
+ // never as ten stacked modals carrying the database's own wording.
4687
+ return this._reportData.componentData(report.id, component.id, filters, state, true, { handlers: false })
4688
+ .pipe(map$1((data) => ({ component, data })), catchError(() => of({ component, data: null })));
4689
+ }
4690
+ // The zone the server resolved, as its IANA id. Only the server's value is
4691
+ // printed: the browser's zone (resolveReportTimezone's fallback for an older
4692
+ // server) is not what the numbers were measured in.
4693
+ _contextLines(report) {
4694
+ return report.effectiveTimezone ? [`Timezone: ${report.effectiveTimezone}`] : [];
4695
+ }
3993
4696
  // One select group's line, with its stored ids resolved to the same names the
3994
4697
  // on-screen chips show — a cover reading "Organization: 8774, 8777" tells the
3995
4698
  // client nothing about what the report covers.
@@ -4022,7 +4725,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
4022
4725
  // complete synchronously after setOption.
4023
4726
  async function renderChartImage(component, data, width, height, pixelRatio = 3) {
4024
4727
  // The same tree-shaken echarts build + 'report' theme the live canvas uses.
4025
- const echarts = (await import('./firestitch-report-echarts-CtKYuRcT.mjs')).default;
4728
+ const echarts = (await import('./firestitch-report-echarts-B9nl9XKM.mjs')).default;
4026
4729
  const host = document.createElement('div');
4027
4730
  host.style.width = `${width}px`;
4028
4731
  host.style.height = `${height}px`;
@@ -4051,8 +4754,19 @@ async function renderChartImage(component, data, width, height, pixelRatio = 3)
4051
4754
  const PT = 72;
4052
4755
  // Component title strip height, points.
4053
4756
  const TITLE_H$1 = 18;
4054
- // Approximate table row height, points — drives the rows-that-fit cap.
4055
- const TABLE_ROW_H$1 = 16;
4757
+ // Rows fetched for a list's PDF table. A table that outgrows its box
4758
+ // continues onto pages of its own, so this is how far it may run: about 12-15
4759
+ // continuation pages on a letter page, and a tenth of the server's 5000-row
4760
+ // ceiling. A longer result ends in a moreRowsNotice() row.
4761
+ const PDF_LIST_ROWS = 500;
4762
+ // How far a continuation table sits inside the header and footer bands,
4763
+ // points. A page with no band (or with the master hidden) keeps
4764
+ // NO_BAND_MARGIN from that edge instead.
4765
+ const CONTINUATION_GAP = 0.25 * PT;
4766
+ const NO_BAND_MARGIN = 0.5 * PT;
4767
+ const NO_BANDS = { headerHeight: 0, footerHeight: 0 };
4768
+ // The last row of a list box whose rows carry on past it.
4769
+ const CONTINUED_NOTICE = 'Continued on the following pages';
4056
4770
  // A text line's height as a multiple of its largest font size — the canvas
4057
4771
  // stylesheet's line-height, so a block wraps and stacks the same way.
4058
4772
  const TEXT_LINE_HEIGHT = 1.25;
@@ -4065,40 +4779,74 @@ const LIST_INDENT = 16;
4065
4779
  // Charts are rendered through an offscreen ECharts instance at 3× pixel
4066
4780
  // ratio — pixel-identical to the screen, crisp at print size — and tables are
4067
4781
  // drawn with jspdf-autotable. Both libraries load lazily into their own chunks.
4782
+ //
4783
+ // A list table is fitted to its box by laying it out first, never by a
4784
+ // row-height estimate, so it cannot spill past the box. The rows that do not
4785
+ // fit continue on pages of their own after the report page, under the same
4786
+ // header/footer and with the same column widths, up to PDF_LIST_ROWS; a
4787
+ // longer result ends in a row pointing to the component's CSV export.
4068
4788
  class ReportPdfService {
4069
4789
  async export(collected) {
4790
+ const context = await this._context(collected.report);
4791
+ this._titlePage(context.doc, collected, context.pageW, context.pageH);
4792
+ // The physical page number {{page.number}} prints: report pages and
4793
+ // their continuation pages alike, the title page not counted.
4794
+ let pageNumber = 0;
4795
+ for (const [index, collectedPage] of collected.pages.entries()) {
4796
+ const overflows = [];
4797
+ pageNumber += 1;
4798
+ await this._renderPage(context, collectedPage, index, pageNumber, overflows);
4799
+ // Each continuation appends its pages at the end of the document, so
4800
+ // two lists that overflowed on one page follow it back to back.
4801
+ for (const overflow of overflows) {
4802
+ pageNumber = this._renderContinuation(context, collectedPage.page, index, pageNumber, overflow);
4803
+ }
4804
+ }
4805
+ context.doc.save(`${this._slug(collected.report.name)}.pdf`);
4806
+ }
4807
+ async _context(report) {
4070
4808
  const { jsPDF } = await import('jspdf');
4071
4809
  const autoTable = (await import('jspdf-autotable')).default;
4072
- const report = collected.report;
4073
4810
  const pageW = report.pageWidth * PT;
4074
4811
  const pageH = report.pageHeight * PT;
4075
- const doc = new jsPDF({
4076
- orientation: pageW >= pageH ? 'landscape' : 'portrait',
4077
- unit: 'pt',
4078
- format: [pageW, pageH],
4079
- });
4080
- this._titlePage(doc, collected, pageW, pageH);
4081
- for (const [index, collectedPage] of collected.pages.entries()) {
4082
- doc.addPage([pageW, pageH], pageW >= pageH ? 'landscape' : 'portrait');
4083
- const geometry = pageGeometry(report, collectedPage.page);
4084
- const tokens = this._tokens(report, collectedPage.page, index);
4085
- // The header and footer first, under the page's own components.
4086
- if (!collectedPage.page.masterHidden) {
4087
- for (const component of report.master?.components ?? []) {
4088
- this._renderText(doc, component, component, tokens);
4089
- }
4812
+ const orientation = pageW >= pageH ? 'landscape' : 'portrait';
4813
+ return {
4814
+ doc: new jsPDF({ orientation, unit: 'pt', format: [pageW, pageH] }),
4815
+ autoTable,
4816
+ jsPDF,
4817
+ report,
4818
+ timezone: resolveReportTimezone(report),
4819
+ pageW,
4820
+ pageH,
4821
+ orientation,
4822
+ };
4823
+ }
4824
+ // One report page: the header and footer first, under the page's own
4825
+ // components. A list that outgrows its box leaves its remaining rows in
4826
+ // overflows.
4827
+ async _renderPage(context, collectedPage, index, pageNumber, overflows) {
4828
+ const { doc, report } = context;
4829
+ const geometry = pageGeometry(report, collectedPage.page);
4830
+ const tokens = this._tokens(report, collectedPage.page, index, pageNumber);
4831
+ doc.addPage([context.pageW, context.pageH], context.orientation);
4832
+ this._renderMaster(context, collectedPage.page, tokens);
4833
+ for (const item of collectedPage.components) {
4834
+ const box = geometry.get(item.component.id);
4835
+ if (item.component.type === 'text') {
4836
+ this._renderText(doc, item.component, box ?? item.component, tokens);
4090
4837
  }
4091
- for (const item of collectedPage.components) {
4092
- const box = geometry.get(item.component.id);
4093
- if (item.component.type === 'text') {
4094
- this._renderText(doc, item.component, box ?? item.component, tokens);
4095
- }
4096
- else {
4097
- await this._renderComponent(doc, autoTable, item, box);
4098
- }
4838
+ else {
4839
+ await this._renderComponent(context, item, overflows, box);
4099
4840
  }
4100
4841
  }
4101
- doc.save(`${this._slug(report.name)}.pdf`);
4842
+ }
4843
+ _renderMaster(context, page, tokens) {
4844
+ if (page.masterHidden) {
4845
+ return;
4846
+ }
4847
+ for (const component of context.report.master?.components ?? []) {
4848
+ this._renderText(context.doc, component, component, tokens);
4849
+ }
4102
4850
  }
4103
4851
  _titlePage(doc, collected, pageW, pageH) {
4104
4852
  doc.setFont('helvetica', 'bold');
@@ -4108,11 +4856,7 @@ class ReportPdfService {
4108
4856
  doc.setFont('helvetica', 'normal');
4109
4857
  doc.setFontSize(11);
4110
4858
  doc.setTextColor('#52606d');
4111
- const lines = [
4112
- format(new Date(), 'MMMM d, yyyy'),
4113
- ...collected.filterSummary,
4114
- ];
4115
- doc.text(lines, 40, pageH * 0.35 + 26);
4859
+ doc.text(coverLines(collected), 40, pageH * 0.35 + 26);
4116
4860
  }
4117
4861
  // A text block, laid out by hand: jsPDF draws strings, not rich text, so each
4118
4862
  // paragraph's runs are broken into words, wrapped to the block's width and
@@ -4237,16 +4981,19 @@ class ReportPdfService {
4237
4981
  return 0;
4238
4982
  }
4239
4983
  }
4240
- // What a text block's tokens fill with on this page: the page's position
4241
- // among the report's pages — the title page is not a report page.
4242
- _tokens(report, page, index) {
4984
+ // What a text block's tokens fill with on this page. The number is the
4985
+ // physical page among the report's pages, continuation pages included — the
4986
+ // title page is not a report page. The name stays the report page's, so a
4987
+ // continuation page is named for the page its table started on.
4988
+ _tokens(report, page, index, pageNumber) {
4243
4989
  return {
4244
- pageNumber: index + 1,
4990
+ pageNumber,
4245
4991
  pageName: page.name || `Page ${index + 1}`,
4246
4992
  reportName: report.name,
4247
4993
  };
4248
4994
  }
4249
- async _renderComponent(doc, autoTable, item, geometry) {
4995
+ async _renderComponent(context, item, overflows, geometry) {
4996
+ const { doc } = context;
4250
4997
  const component = item.component;
4251
4998
  // Geometry arrives in inches (flow layout pre-resolved); the PDF draws in
4252
4999
  // points.
@@ -4256,18 +5003,12 @@ class ReportPdfService {
4256
5003
  const w = inches.w * PT;
4257
5004
  const h = inches.h * PT;
4258
5005
  if (component.title) {
4259
- doc.setFont('helvetica', 'bold');
4260
- doc.setFontSize(9);
4261
- doc.setTextColor('#1f2933');
4262
- doc.text(component.title, x, y + 10, { maxWidth: w });
5006
+ this._componentTitle(doc, component.title, x, y, w);
4263
5007
  }
4264
5008
  const bodyY = y + TITLE_H$1;
4265
5009
  const bodyH = Math.max(20, h - TITLE_H$1);
4266
5010
  if (!item.data || !item.data.rows?.length) {
4267
- doc.setFont('helvetica', 'italic');
4268
- doc.setFontSize(9);
4269
- doc.setTextColor('#7b8794');
4270
- doc.text(emptyLabel(item), x + w / 2, bodyY + bodyH / 2, { align: 'center' });
5011
+ this._boxNote(doc, emptyLabel(item), x, bodyY, w, bodyH);
4271
5012
  return;
4272
5013
  }
4273
5014
  switch (component.type) {
@@ -4277,36 +5018,173 @@ class ReportPdfService {
4277
5018
  break;
4278
5019
  }
4279
5020
  case 'list':
4280
- this._renderTable(doc, autoTable, component, item.data, x, bodyY, w, bodyH);
5021
+ this._renderTable(context, component, item.data, x, bodyY, w, bodyH, overflows);
4281
5022
  break;
4282
5023
  case 'kpi':
4283
5024
  this._renderKpi(doc, component, item.data, x, bodyY, w, bodyH);
4284
5025
  break;
4285
5026
  }
4286
5027
  }
4287
- _renderTable(doc, autoTable, component, data, x, y, w, h) {
4288
- const config = component.config;
4289
- const columns = config.columns?.length
4290
- ? config.columns
4291
- : data.columns.map((column) => ({ column, label: column }));
4292
- const cap = Math.max(2, Math.floor(h / TABLE_ROW_H$1) - 1);
4293
- const rows = data.rows.slice(0, cap);
4294
- const remaining = (data.paging?.total ?? data.rows.length) - rows.length;
4295
- const body = rows.map((row) => columns.map((column) => this._cell(row[column.column], column.format)));
4296
- if (remaining > 0) {
4297
- body.push([`… ${remaining} more row(s) — use the component's CSV export for the full dataset`]);
5028
+ // The bold strip over a component, and over each of its continuation pages.
5029
+ _componentTitle(doc, title, x, y, w) {
5030
+ doc.setFont('helvetica', 'bold');
5031
+ doc.setFontSize(9);
5032
+ doc.setTextColor('#1f2933');
5033
+ doc.text(title, x, y + 10, { maxWidth: w });
5034
+ }
5035
+ // An italic grey line centered in a component's body, for a body that holds
5036
+ // something other than its content.
5037
+ _boxNote(doc, text, x, y, w, h) {
5038
+ doc.setFont('helvetica', 'italic');
5039
+ doc.setFontSize(9);
5040
+ doc.setTextColor('#7b8794');
5041
+ doc.text(text, x + w / 2, y + h / 2, { align: 'center' });
5042
+ }
5043
+ // A list's on-page table. All of its fetched rows are laid out once to learn
5044
+ // how many the box holds; when they do not all fit, the box's last row says
5045
+ // the table continues and the rest go to overflows for continuation pages.
5046
+ // A box too short for even the head carries the notice as a line instead.
5047
+ // Points throughout.
5048
+ _renderTable(context, component, data, x, y, w, h, overflows) {
5049
+ const head = listHeadings(component, data);
5050
+ const cells = this._tableCells(component, data, context.timezone);
5051
+ const omitted = Math.max(0, (data.paging?.total ?? cells.length) - cells.length);
5052
+ const bottom = context.pageH - (y + h);
5053
+ const { fit, columnStyles } = this._measure(context, head, cells, x, y, w, bottom);
5054
+ if (fit === cells.length && omitted === 0) {
5055
+ this._drawTable(context, this._tableOptions(context, head, cells, x, y, w, bottom, columnStyles));
5056
+ return;
5057
+ }
5058
+ // The notice takes the place of the last row that fit, so that row moves
5059
+ // to the continuation. omitted > 0 means a full PDF_LIST_ROWS fetch, which
5060
+ // never fits one box, so the CSV notice always lands on a continuation
5061
+ // page.
5062
+ const kept = Math.max(0, fit - 1);
5063
+ overflows.push({ component, head, rows: cells.slice(kept), omitted, x, w, columnStyles });
5064
+ if (fit >= 1) {
5065
+ const body = [...cells.slice(0, kept), this._noticeRow(CONTINUED_NOTICE, head.length, true)];
5066
+ this._drawTable(context, this._tableOptions(context, head, body, x, y, w, bottom, columnStyles));
5067
+ }
5068
+ else {
5069
+ this._boxNote(context.doc, CONTINUED_NOTICE, x, y, w, h);
4298
5070
  }
4299
- autoTable(doc, {
4300
- head: [columns.map((column) => column.label || column.column)],
5071
+ }
5072
+ // Each fetched row as the strings its table cells print.
5073
+ _tableCells(component, data, timezone) {
5074
+ const columns = listColumns(component, data);
5075
+ return data.rows.map((row) => columns.map((column) => exportCell(row[column.column], column.format, timezone)));
5076
+ }
5077
+ // The autoTable options every list table shares: the dry run, the on-page
5078
+ // draw and the continuation pages, so the three lay a table out alike. The
5079
+ // font is named rather than inherited, since the doc carries whatever the
5080
+ // last text drawn left set. bottom is the margin under the table, points.
5081
+ _tableOptions(context, head, body, x, startY, w, bottom, columnStyles) {
5082
+ return {
5083
+ theme: 'grid',
5084
+ head: [head],
4301
5085
  body,
4302
- startY: y,
4303
- margin: { left: x },
5086
+ startY,
5087
+ margin: { left: x, right: context.pageW - x - w, bottom },
4304
5088
  tableWidth: w,
4305
- styles: { fontSize: 8, cellPadding: 3, textColor: '#1f2933' },
5089
+ rowPageBreak: 'avoid',
5090
+ styles: { font: 'helvetica', fontStyle: 'normal', fontSize: 8, cellPadding: 3, textColor: '#1f2933' },
4306
5091
  headStyles: { fillColor: '#4e79a7', textColor: '#ffffff', fontStyle: 'bold' },
4307
5092
  alternateRowStyles: { fillColor: '#f5f7fa' },
4308
- theme: 'grid',
5093
+ ...(columnStyles ? { columnStyles } : {}),
5094
+ };
5095
+ }
5096
+ // A notice as a table row: one italic grey cell spanning every column. A
5097
+ // single-line notice is ellipsized at the box's width and keeps the data
5098
+ // cells' font size and padding, so it is never taller than the data row
5099
+ // whose place it takes.
5100
+ _noticeRow(text, span, singleLine) {
5101
+ return [{
5102
+ content: text,
5103
+ colSpan: span,
5104
+ styles: { fontStyle: 'italic', textColor: '#7b8794', ...(singleLine ? { overflow: 'ellipsize' } : {}) },
5105
+ }];
5106
+ }
5107
+ // Lays every fetched row out once on a scratch document of the same page
5108
+ // size, with the options the real draw uses, and reads back how many body
5109
+ // rows the box's page holds (fit) and the column widths autoTable chose.
5110
+ // fit 0 means even the head did not fit: autoTable moved to a new page
5111
+ // before drawing. The real document is untouched.
5112
+ _measure(context, head, cells, x, y, w, bottom) {
5113
+ const scratch = new context.jsPDF({
5114
+ unit: 'pt',
5115
+ format: [context.pageW, context.pageH],
5116
+ orientation: context.orientation,
5117
+ });
5118
+ let fit = 0;
5119
+ context.autoTable(scratch, {
5120
+ ...this._tableOptions(context, head, cells, x, y, w, bottom),
5121
+ didDrawCell: (hook) => {
5122
+ if (hook.section === 'body' && scratch.getCurrentPageInfo().pageNumber === 1) {
5123
+ fit = Math.max(fit, hook.row.index + 1);
5124
+ }
5125
+ },
4309
5126
  });
5127
+ const columns = scratch.lastAutoTable.columns;
5128
+ const columnStyles = {};
5129
+ columns.forEach((column, index) => {
5130
+ columnStyles[index] = { cellWidth: column.width };
5131
+ });
5132
+ // A row taller than a whole page is split rather than moved, so a last
5133
+ // row that started on the first page can still have run past it.
5134
+ if (fit === cells.length && scratch.getNumberOfPages() > 1) {
5135
+ fit -= 1;
5136
+ }
5137
+ return { fit, columnStyles };
5138
+ }
5139
+ // Draws an on-page table, then removes any page the draw added. That should
5140
+ // never happen: the measured fit, the identical column widths and a
5141
+ // single-line notice keep the table inside its box. The check exists so a
5142
+ // stray page can never catch the report page's remaining components.
5143
+ _drawTable(context, options) {
5144
+ const { doc } = context;
5145
+ const page = doc.getCurrentPageInfo().pageNumber;
5146
+ const pages = doc.getNumberOfPages();
5147
+ context.autoTable(doc, options);
5148
+ while (doc.getNumberOfPages() > pages) {
5149
+ doc.deletePage(doc.getNumberOfPages());
5150
+ }
5151
+ doc.setPage(page);
5152
+ }
5153
+ // The rows of one list that did not fit its box, on pages appended after
5154
+ // the report page, each with the page's header and footer, the table's
5155
+ // title and its head again. The table sits CONTINUATION_GAP inside the
5156
+ // bands. Returns the physical page counter after these pages.
5157
+ _renderContinuation(context, page, index, pageNumber, overflow) {
5158
+ const { doc, report } = context;
5159
+ const bands = page.masterHidden ? NO_BANDS : report.config?.headerFooter ?? NO_BANDS;
5160
+ const top = this._continuationMargin(bands.headerHeight);
5161
+ const bottom = this._continuationMargin(bands.footerHeight);
5162
+ const { x, w } = overflow;
5163
+ const body = overflow.omitted > 0
5164
+ ? [...overflow.rows, this._noticeRow(moreRowsNotice(overflow.omitted), overflow.head.length, false)]
5165
+ : overflow.rows;
5166
+ let counter = pageNumber;
5167
+ doc.addPage([context.pageW, context.pageH], context.orientation);
5168
+ context.autoTable(doc, {
5169
+ ...this._tableOptions(context, overflow.head, body, x, top + TITLE_H$1, w, bottom, overflow.columnStyles),
5170
+ margin: { top: top + TITLE_H$1, bottom, left: x, right: context.pageW - x - w },
5171
+ showHead: 'everyPage',
5172
+ // Fires on every page the table draws on, the first included.
5173
+ willDrawPage: () => {
5174
+ counter += 1;
5175
+ this._renderMaster(context, page, this._tokens(report, page, index, counter));
5176
+ this._componentTitle(doc, this._continuedTitle(overflow.component), x, top, w);
5177
+ },
5178
+ });
5179
+ return counter;
5180
+ }
5181
+ // The margin a continuation table keeps from one edge: inside the band when
5182
+ // the page has one.
5183
+ _continuationMargin(band) {
5184
+ return band ? band * PT + CONTINUATION_GAP : NO_BAND_MARGIN;
5185
+ }
5186
+ _continuedTitle(component) {
5187
+ return component.title ? `${component.title} (continued)` : 'Continued';
4310
5188
  }
4311
5189
  _renderKpi(doc, component, data, x, y, w, h) {
4312
5190
  const config = component.config;
@@ -4324,20 +5202,6 @@ class ReportPdfService {
4324
5202
  doc.text(config.measure.label, x + w / 2, y + h / 2 + 16, { align: 'center' });
4325
5203
  }
4326
5204
  }
4327
- // The exported cell must read like the on-screen cell: the SQL already
4328
- // carries the formatting, so the value goes out verbatim. A raw date is the
4329
- // one value the client renders (the column stays a RAW timestamp for the
4330
- // report timezone), and it is rendered here too.
4331
- _cell(value, columnFormat) {
4332
- if (value === null || value === undefined) {
4333
- return '';
4334
- }
4335
- if (columnFormat === 'date') {
4336
- const date = new Date(String(value));
4337
- return Number.isNaN(date.getTime()) ? String(value) : format(date, 'MMM d, yyyy');
4338
- }
4339
- return String(value);
4340
- }
4341
5205
  _slug(name) {
4342
5206
  return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'report';
4343
5207
  }
@@ -4351,7 +5215,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
4351
5215
  // Component title strip height on the slide, inches.
4352
5216
  const TITLE_H = 0.28;
4353
5217
  // Approximate table row height, inches — drives the rows-that-fit cap.
4354
- const TABLE_ROW_H = 0.26;
5218
+ const SLIDE_ROW_H = 0.26;
5219
+ // Rows fetched for a list's slide table. A slide table never continues onto
5220
+ // another slide, so this only has to cover the rows a box can show; the
5221
+ // moreRowsNotice() row names the rest.
5222
+ const PPTX_LIST_ROWS = 100;
4355
5223
  // Our legend positions mapped to pptxgenjs's single-letter legendPos codes.
4356
5224
  // 'hidden' has no slot — showLegend is turned off instead — so it falls back
4357
5225
  // to top.
@@ -4369,6 +5237,10 @@ const WATERFALL_SERIES_NAMES = {
4369
5237
  fall: 'Decrease',
4370
5238
  total: 'Total',
4371
5239
  };
5240
+ // A null is a period with no value. The screen breaks the line there, and
5241
+ // pptxgenjs's default ('span') would draw straight across it. The setting is
5242
+ // chart-level, so a combo's every type inherits it through the chart options.
5243
+ const BLANKS_AS_GAPS = { displayBlanksAs: 'gap' };
4372
5244
  // Builds the PowerPoint deck client-side with pptxgenjs. Charts are NATIVE
4373
5245
  // editable PowerPoint charts (slide.addChart writes real chart XML with an
4374
5246
  // embedded data workbook — "Edit Data" works), never images. The page IS the
@@ -4386,6 +5258,7 @@ class ReportPptxService {
4386
5258
  pptx.layout = 'REPORT';
4387
5259
  pptx.title = report.name;
4388
5260
  this._titleSlide(pptx, collected, pageW, pageH);
5261
+ const timezone = resolveReportTimezone(report);
4389
5262
  collected.pages.forEach((collectedPage, index) => {
4390
5263
  const slide = pptx.addSlide();
4391
5264
  const geometry = pageGeometry(report, collectedPage.page);
@@ -4403,7 +5276,7 @@ class ReportPptxService {
4403
5276
  this._renderText(slide, item.component, box ?? item.component, tokens);
4404
5277
  }
4405
5278
  else {
4406
- this._renderComponent(pptx, slide, item, box);
5279
+ this._renderComponent(pptx, slide, item, timezone, box);
4407
5280
  }
4408
5281
  }
4409
5282
  });
@@ -4415,10 +5288,7 @@ class ReportPptxService {
4415
5288
  x: 0.5, y: pageH * 0.3, w: pageW - 1, h: 0.8,
4416
5289
  fontSize: 30, bold: true, color: '1F2933',
4417
5290
  });
4418
- const lines = [
4419
- format(new Date(), 'MMMM d, yyyy'),
4420
- ...collected.filterSummary,
4421
- ];
5291
+ const lines = coverLines(collected);
4422
5292
  slide.addText(lines.join('\n'), {
4423
5293
  x: 0.5, y: pageH * 0.3 + 0.9, w: pageW - 1, h: Math.max(0.4, lines.length * 0.25),
4424
5294
  fontSize: 12, color: '52606D',
@@ -4480,7 +5350,7 @@ class ReportPptxService {
4480
5350
  reportName: report.name,
4481
5351
  };
4482
5352
  }
4483
- _renderComponent(pptx, slide, item, geometry) {
5353
+ _renderComponent(pptx, slide, item, timezone, geometry) {
4484
5354
  const component = item.component;
4485
5355
  // Geometry is in inches — PowerPoint's native unit (flow layout arrives
4486
5356
  // pre-resolved into rows by pageGeometry).
@@ -4505,7 +5375,7 @@ class ReportPptxService {
4505
5375
  this._renderChart(pptx, slide, component, item.data, x, bodyY, w, bodyH);
4506
5376
  break;
4507
5377
  case 'list':
4508
- this._renderTable(slide, component, item.data, x, bodyY, w, bodyH);
5378
+ this._renderTable(slide, component, item.data, timezone, x, bodyY, w, bodyH);
4509
5379
  break;
4510
5380
  case 'kpi':
4511
5381
  this._renderKpi(slide, component, item.data, x, bodyY, w, bodyH);
@@ -4539,19 +5409,27 @@ class ReportPptxService {
4539
5409
  ? [{
4540
5410
  name: config.measures?.[0]?.label ?? config.colorBy?.label ?? '',
4541
5411
  labels,
4542
- values: extracted.categories.map((_, index) => extracted.series[index]?.values[index] ?? 0),
5412
+ values: extracted.categories.map((_, index) => extracted.series[index]?.values[index] ?? null),
4543
5413
  }]
4544
5414
  : (pie ? extracted.series.slice(0, 1) : extracted.series)
4545
5415
  .map((series) => ({
4546
5416
  name: series.name,
4547
5417
  labels,
4548
- values: series.values.map((value) => value ?? 0),
5418
+ // A pie keeps the 0 the screen's pie uses; an axis chart keeps its
5419
+ // null so the point is a gap.
5420
+ values: pie ? series.values.map((value) => value ?? 0) : series.values,
4549
5421
  }));
4550
5422
  // A single-series colorBy chart can't carry a per-category legend in a native
4551
5423
  // PowerPoint bar chart; the category axis labels identify each bar instead.
4552
5424
  const legendPosition = colorBy
4553
5425
  ? 'hidden'
4554
5426
  : resolveLegendPosition(config, extracted.series.length);
5427
+ // The type each chartData entry is drawn as. A pie is one entry of its own
5428
+ // type; colorBy collapsed its series into one, drawn as the lone measure's.
5429
+ const seriesDataTypes = pie
5430
+ ? [chartType]
5431
+ : seriesTypes(extracted, chartType).slice(0, colorBy ? 1 : undefined);
5432
+ const dataTypes = seriesDataTypes.length ? seriesDataTypes : [chartType];
4555
5433
  const options = {
4556
5434
  x, y, w, h,
4557
5435
  chartColors: REPORT_CHART_COLORS,
@@ -4562,14 +5440,28 @@ class ReportPptxService {
4562
5440
  barGrouping: config.stacked ? 'stacked' : 'clustered',
4563
5441
  holeSize: chartType === 'donut' ? 55 : undefined,
4564
5442
  ...this._axisTextOptions(config, pie),
5443
+ ...BLANKS_AS_GAPS,
5444
+ // A pie has no axes. A combo's types inherit these from the chart level.
5445
+ ...(pie ? {} : {
5446
+ valAxisLabelFormatCode: chartAxisFormatCode(chartData.map((entry) => entry.values), !!config.stacked),
5447
+ ...this._categoryOrderOptions(config, dataTypes),
5448
+ }),
4565
5449
  dataBorder: { pt: 0, color: 'FFFFFF' },
4566
5450
  };
4567
- // The type each chartData entry is drawn as. A pie is one entry of its own
4568
- // type; colorBy collapsed its series into one, drawn as the lone measure's.
4569
- const dataTypes = pie
4570
- ? [chartType]
4571
- : seriesTypes(extracted, chartType).slice(0, colorBy ? 1 : undefined);
4572
- this._addChart(slide, typeMap, dataTypes.length ? dataTypes : [chartType], chartData, options);
5451
+ this._addChart(slide, typeMap, dataTypes, chartData, options);
5452
+ }
5453
+ // A horizontal bar chart reads top-down, first category at the top, as it
5454
+ // does on screen. PowerPoint draws category 0 next to the value axis, at the
5455
+ // bottom, so the category axis is reversed ('maxMin'); the value axis then
5456
+ // crosses at the last category ('max'), which now sits at the bottom, and
5457
+ // 'nextTo' keeps its labels on that line (pptxgenjs's default for a bar
5458
+ // direction, 'low', would follow the first category to the top). Only an
5459
+ // all-bar chart: a line or area is never drawn sideways.
5460
+ _categoryOrderOptions(config, types) {
5461
+ if (config.orientation !== 'horizontal' || types.some((type) => type !== 'bar')) {
5462
+ return {};
5463
+ }
5464
+ return { catAxisOrientation: 'maxMin', catAxisCrossesAt: 'max', valAxisLabelPos: 'nextTo' };
4573
5465
  }
4574
5466
  // One native chart when every entry is drawn as the same type. A combo is
4575
5467
  // pptxgenjs's multi-type chart: one entry per type, all on the same category
@@ -4609,6 +5501,11 @@ class ReportPptxService {
4609
5501
  barDir: config.orientation === 'horizontal' ? 'bar' : 'col',
4610
5502
  barGrouping: 'stacked',
4611
5503
  ...this._axisTextOptions(config, false),
5504
+ ...BLANKS_AS_GAPS,
5505
+ // The axis spans the running total, so its peak is the furthest a step
5506
+ // ends from zero; the pieces it is cut into never stack past that.
5507
+ valAxisLabelFormatCode: chartAxisFormatCode([waterfall.steps.map((step) => step.start + (step.change ?? 0))], false),
5508
+ ...this._categoryOrderOptions(config, ['bar']),
4612
5509
  // A running total can go below zero, where the category axis crosses;
4613
5510
  // its labels stay at the plot's edge instead of across the bars.
4614
5511
  catAxisLabelPos: 'low',
@@ -4663,25 +5560,22 @@ class ReportPptxService {
4663
5560
  valAxisTitleFontSize: 10,
4664
5561
  };
4665
5562
  }
4666
- _renderTable(slide, component, data, x, y, w, h) {
4667
- const config = component.config;
4668
- const columns = config.columns?.length
4669
- ? config.columns
4670
- : data.columns.map((column) => ({ column, label: column }));
4671
- const cap = Math.max(2, Math.floor(h / TABLE_ROW_H) - 1);
5563
+ _renderTable(slide, component, data, timezone, x, y, w, h) {
5564
+ const columns = listColumns(component, data);
5565
+ const cap = Math.max(2, Math.floor(h / SLIDE_ROW_H) - 1);
4672
5566
  const rows = data.rows.slice(0, cap);
4673
5567
  const remaining = (data.paging?.total ?? data.rows.length) - rows.length;
4674
- const header = columns.map((column) => ({
4675
- text: column.label || column.column,
5568
+ const header = listHeadings(component, data).map((heading) => ({
5569
+ text: heading,
4676
5570
  options: { bold: true, color: 'FFFFFF', fill: { color: '4E79A7' } },
4677
5571
  }));
4678
5572
  const body = rows.map((row) => columns.map((column) => ({
4679
- text: this._cell(row[column.column], column.format),
5573
+ text: exportCell(row[column.column], column.format, timezone),
4680
5574
  options: {},
4681
5575
  })));
4682
5576
  if (remaining > 0) {
4683
5577
  body.push([{
4684
- text: `… ${remaining} more row(s) — use the component's CSV export for the full dataset`,
5578
+ text: moreRowsNotice(remaining),
4685
5579
  options: { colspan: columns.length, italic: true, color: '7B8794' },
4686
5580
  }]);
4687
5581
  }
@@ -4691,7 +5585,7 @@ class ReportPptxService {
4691
5585
  color: '1F2933',
4692
5586
  border: { type: 'solid', pt: 0.5, color: 'E4E7EB' },
4693
5587
  autoPage: false,
4694
- rowH: TABLE_ROW_H,
5588
+ rowH: SLIDE_ROW_H,
4695
5589
  valign: 'middle',
4696
5590
  });
4697
5591
  }
@@ -4709,20 +5603,6 @@ class ReportPptxService {
4709
5603
  valign: 'middle',
4710
5604
  });
4711
5605
  }
4712
- // The exported cell must read like the on-screen cell: the SQL already
4713
- // carries the formatting, so the value goes out verbatim. A raw date is the
4714
- // one value the client renders (the column stays a RAW timestamp for the
4715
- // report timezone), and it is rendered here too.
4716
- _cell(value, columnFormat) {
4717
- if (value === null || value === undefined) {
4718
- return '';
4719
- }
4720
- if (columnFormat === 'date') {
4721
- const date = new Date(String(value));
4722
- return Number.isNaN(date.getTime()) ? String(value) : format(date, 'MMM d, yyyy');
4723
- }
4724
- return String(value);
4725
- }
4726
5606
  _slug(name) {
4727
5607
  return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'report';
4728
5608
  }
@@ -4755,20 +5635,23 @@ class ReportExportService {
4755
5635
  _pptxService = inject(ReportPptxService);
4756
5636
  _prompt = inject(FsPrompt);
4757
5637
  pdf(report) {
4758
- return this._run(report, (collected) => this._pdfService.export(collected));
5638
+ return this._run(report, PDF_LIST_ROWS, (collected) => this._pdfService.export(collected));
4759
5639
  }
4760
5640
  powerpoint(report) {
4761
- return this._run(report, (collected) => this._pptxService.export(collected));
5641
+ return this._run(report, PPTX_LIST_ROWS, (collected) => this._pptxService.export(collected));
4762
5642
  }
4763
5643
  // Retry re-collects the WHOLE report, not just the tiles that failed. Every
4764
5644
  // number in one file has to have been true at one moment: refetching four
4765
5645
  // tiles and keeping nine from three minutes ago produces a deck whose pages
4766
5646
  // disagree with each other, and nothing on it would say so.
4767
- _run(report, render) {
4768
- return this._collector.collect(report)
5647
+ //
5648
+ // listRows is the format's own list cap, carried into the retry so a second
5649
+ // attempt fetches what the first did.
5650
+ _run(report, listRows, render) {
5651
+ return this._collector.collect(report, listRows)
4769
5652
  .pipe(switchMap$1((collected) => this._gate(collected)
4770
5653
  .pipe(switchMap$1((choice) => choice === RETRY
4771
- ? this._run(report, render)
5654
+ ? this._run(report, listRows, render)
4772
5655
  : from(render(collected))))));
4773
5656
  }
4774
5657
  // Resolves to DOWNLOAD when the export is whole (or the user accepted an
@@ -4813,7 +5696,7 @@ class ReportService {
4813
5696
  }
4814
5697
  load() {
4815
5698
  return this._reportData.reports()
4816
- .pipe(tap((reports) => this._reports$.next(reports ?? [])));
5699
+ .pipe(tap$1((reports) => this._reports$.next(reports ?? [])));
4817
5700
  }
4818
5701
  // Filters the cached list locally — what the autocomplete's [fetch] calls.
4819
5702
  filter(keyword) {
@@ -4826,20 +5709,20 @@ class ReportService {
4826
5709
  }
4827
5710
  create(name) {
4828
5711
  return this._reportData.create(name)
4829
- .pipe(tap((report) => {
5712
+ .pipe(tap$1((report) => {
4830
5713
  this._reports$.next([...this._reports$.getValue(), report]);
4831
5714
  }));
4832
5715
  }
4833
5716
  rename(reportId, name) {
4834
5717
  return this._reportData.rename(reportId, name)
4835
- .pipe(tap((report) => {
5718
+ .pipe(tap$1((report) => {
4836
5719
  this._reports$.next(this._reports$.getValue()
4837
5720
  .map((existing) => (existing.id === report.id ? report : existing)));
4838
5721
  }));
4839
5722
  }
4840
5723
  delete(reportId) {
4841
5724
  return this._reportData.delete(reportId)
4842
- .pipe(tap(() => {
5725
+ .pipe(tap$1(() => {
4843
5726
  this._reports$.next(this._reports$.getValue().filter((report) => report.id !== reportId));
4844
5727
  }), map(() => undefined));
4845
5728
  }
@@ -4859,15 +5742,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
4859
5742
  // turn via [requestData].
4860
5743
  class ReportComponent {
4861
5744
  // The single report the viewer and chat are focused on.
4862
- selected = null;
4863
- report = null;
4864
- loadingReports = true;
4865
- editMode = false;
5745
+ selected = signal(null);
5746
+ report = signal(null);
5747
+ loadingReports = signal(true);
5748
+ editMode = signal(false);
4866
5749
  // The report toolbar IS an fs-filter: the picker rides in its heading
4867
5750
  // template, the menu becomes its actions, and report-level filter groups
4868
5751
  // become its items. Rebuilt whenever the loaded report (and thus its groups)
4869
5752
  // changes; the menu's show-guards keep working while no report is selected.
4870
- reportFilterConfig;
5753
+ reportFilterConfig = signal(null);
4871
5754
  _split;
4872
5755
  _chatPanel;
4873
5756
  // Only the canvas knows which page is on screen, and a page-scoped export
@@ -4880,18 +5763,17 @@ class ReportComponent {
4880
5763
  _reportService = inject(ReportService);
4881
5764
  _filterState = inject(ReportFilterStateService);
4882
5765
  _exportService = inject(ReportExportService);
5766
+ _collector = inject(ReportExportCollectorService);
4883
5767
  _process = inject(FsProcess);
4884
5768
  _prompt = inject(FsPrompt);
4885
5769
  _dialog = inject(MatDialog);
4886
5770
  _destroyRef = inject(DestroyRef);
4887
- _cdRef = inject(ChangeDetectorRef);
4888
5771
  _zone = inject(NgZone);
4889
5772
  constructor() {
4890
5773
  this._buildReportConfig();
4891
5774
  this._reportService.load()
4892
5775
  .pipe(finalize(() => {
4893
- this.loadingReports = false;
4894
- this._cdRef.markForCheck();
5776
+ this.loadingReports.set(false);
4895
5777
  }), takeUntilDestroyed(this._destroyRef))
4896
5778
  .subscribe({ error: () => undefined });
4897
5779
  }
@@ -4899,8 +5781,8 @@ class ReportComponent {
4899
5781
  return this._reportService.filter(keyword);
4900
5782
  };
4901
5783
  reportChange(report) {
4902
- this.report = null;
4903
- this.editMode = false;
5784
+ this.report.set(null);
5785
+ this.editMode.set(false);
4904
5786
  this._buildReportConfig();
4905
5787
  if (report) {
4906
5788
  this._loadReport(report.id);
@@ -4915,13 +5797,12 @@ class ReportComponent {
4915
5797
  })
4916
5798
  .pipe(switchMap$1((name) => this._reportService.create(name)), takeUntilDestroyed(this._destroyRef))
4917
5799
  .subscribe((report) => {
4918
- this.selected = report;
4919
- this._cdRef.markForCheck();
5800
+ this.selected.set(report);
4920
5801
  this._loadReport(report.id);
4921
5802
  });
4922
5803
  }
4923
5804
  toggleEditMode() {
4924
- this.editMode = !this.editMode;
5805
+ this.editMode.update((editMode) => !editMode);
4925
5806
  }
4926
5807
  // The edit tools live in this toolbar, but the canvas owns what they act on
4927
5808
  // (the active page, the header/footer bands, which layer is live).
@@ -4938,11 +5819,11 @@ class ReportComponent {
4938
5819
  // list and structure both reload on save (a layout switch re-renders the
4939
5820
  // whole canvas).
4940
5821
  reportSettings() {
4941
- if (!this.report) {
5822
+ if (!this.report()) {
4942
5823
  return;
4943
5824
  }
4944
5825
  this._dialog.open(ReportSettingsComponent, {
4945
- data: { report: this.report },
5826
+ data: { report: this.report() },
4946
5827
  width: '460px',
4947
5828
  })
4948
5829
  .afterClosed()
@@ -4951,26 +5832,25 @@ class ReportComponent {
4951
5832
  // The dialog confirms + performs the delete itself; we just react to
4952
5833
  // what it reports back.
4953
5834
  if (result?.action === 'deleted') {
4954
- this.selected = null;
4955
- this.report = null;
5835
+ this.selected.set(null);
5836
+ this.report.set(null);
4956
5837
  this._buildReportConfig();
4957
- this._cdRef.markForCheck();
4958
5838
  return;
4959
5839
  }
4960
- if (result?.action === 'saved' && this.selected) {
4961
- this._loadReport(this.selected.id);
4962
- this._refreshReportName(this.selected.id);
5840
+ if (result?.action === 'saved' && this.selected()) {
5841
+ this._loadReport(this.selected().id);
5842
+ this._refreshReportName(this.selected().id);
4963
5843
  }
4964
5844
  });
4965
5845
  }
4966
5846
  // Component settings — title, exact geometry, and the diagnostic views
4967
5847
  // (SQL, config, filter wiring).
4968
5848
  componentSettings(component) {
4969
- if (!this.report) {
5849
+ if (!this.report()) {
4970
5850
  return;
4971
5851
  }
4972
5852
  const dialogRef = this._dialog.open(ComponentSettingsComponent, {
4973
- data: { report: this.report, component },
5853
+ data: { report: this.report(), component },
4974
5854
  width: '640px',
4975
5855
  });
4976
5856
  // Filter add/remove/level changes persist immediately inside the dialog (no
@@ -4988,16 +5868,16 @@ class ReportComponent {
4988
5868
  // gone from the report, so reload rather than patching it out locally —
4989
5869
  // its filters went with it, which the report bar has to be rebuilt for.
4990
5870
  if (result?.deleted) {
4991
- if (this.selected) {
4992
- this._loadReport(this.selected.id);
5871
+ if (this.selected()) {
5872
+ this._loadReport(this.selected().id);
4993
5873
  }
4994
5874
  return;
4995
5875
  }
4996
5876
  // The report's filter groups changed, so the report bar and the
4997
5877
  // on-component strips have to be rebuilt from a fresh report.
4998
5878
  if (filtersChanged) {
4999
- if (this.selected) {
5000
- this._loadReport(this.selected.id);
5879
+ if (this.selected()) {
5880
+ this._loadReport(this.selected().id);
5001
5881
  }
5002
5882
  return;
5003
5883
  }
@@ -5012,8 +5892,8 @@ class ReportComponent {
5012
5892
  // The canvas changed the report's structure (added a page) — re-fetch so the
5013
5893
  // new page renders; the canvas lands the view on it.
5014
5894
  onCanvasReportChanged() {
5015
- if (this.selected) {
5016
- this._loadReport(this.selected.id);
5895
+ if (this.selected()) {
5896
+ this._loadReport(this.selected().id);
5017
5897
  }
5018
5898
  }
5019
5899
  // Export the report to PowerPoint, entirely client-side: every component's
@@ -5021,32 +5901,37 @@ class ReportComponent {
5021
5901
  // the visible one), then pptxgenjs builds a deck of NATIVE editable charts —
5022
5902
  // page = slide, positions 1:1. The dock shows progress until the file saves.
5023
5903
  exportPowerpoint() {
5024
- if (!this.report) {
5904
+ if (!this.report()) {
5025
5905
  return;
5026
5906
  }
5027
- this._process.run('Exporting PowerPoint', this._exportService.powerpoint(this.report));
5907
+ this._process.run('Exporting PowerPoint', this._exportService.powerpoint(this.report()));
5028
5908
  }
5029
5909
  // Export to PDF, entirely client-side: same collection pass, charts rendered
5030
5910
  // through an offscreen ECharts instance at 3× pixel ratio (pixel-identical
5031
5911
  // to the screen), tables via jspdf-autotable — page = PDF page.
5032
5912
  exportPdf() {
5033
- if (!this.report) {
5913
+ if (!this.report()) {
5034
5914
  return;
5035
5915
  }
5036
- this._process.run('Exporting PDF', this._exportService.pdf(this.report));
5916
+ this._process.run('Exporting PDF', this._exportService.pdf(this.report()));
5037
5917
  }
5038
- // The raw data behind the whole report in one action: the server exports every
5039
- // component's full filtered dataset and zips one CSV per component, foldered
5040
- // by page. `thisPage` narrows it to the page the canvas is showing. Unlike the
5041
- // PDF/PowerPoint exports this is entirely server-side — nothing is collected
5042
- // in the browser, so it is not capped by what a slide can hold.
5918
+ // The whole report as files in one action: the server writes one CSV per
5919
+ // component, each what the screen shows (a chart or KPI as drawn, at the
5920
+ // viewer's group-by grain; a list in full), and zips them foldered by page
5921
+ // with an Export Notes.txt that records the filters as the bar reads them.
5922
+ // Lists use their saved default sort; a header click is carried only by a
5923
+ // component's own CSV export. `thisPage` narrows it to the page the canvas is
5924
+ // showing. Unlike the PDF/PowerPoint exports the files are written
5925
+ // server-side, so they are not capped by what a slide can hold.
5043
5926
  exportData(thisPage = false) {
5044
- const report = this.report;
5927
+ const report = this.report();
5045
5928
  if (!report) {
5046
5929
  return;
5047
5930
  }
5048
5931
  const pageId = thisPage ? this._canvas?.activePage?.id ?? null : null;
5049
- this._process.download('Exporting data', this._reportData.exportReport(report.id, this._filterState.resolveForReport(report, pageId), pageId));
5932
+ const filters = this._filterState.resolveForReport(report, pageId);
5933
+ this._process.download('Exporting data', this._collector.getFilterSummary(report)
5934
+ .pipe(switchMap$1((lines) => this._reportData.exportReport(report.id, filters, pageId, this._filterState.frequency(), lines))));
5050
5935
  }
5051
5936
  // React to each chat turn (fs-chat handles the request + persistence); we
5052
5937
  // only care about the report side effects this context's backend flags.
@@ -5054,12 +5939,12 @@ class ReportComponent {
5054
5939
  // The agent changed the report's structure — re-fetch it. Session filter
5055
5940
  // values survive (the filter-state service keeps values for groups that
5056
5941
  // still exist).
5057
- if (response.reportChanged && this.selected) {
5058
- this._loadReport(this.selected.id);
5942
+ if (response.reportChanged && this.selected()) {
5943
+ this._loadReport(this.selected().id);
5059
5944
  }
5060
5945
  // A rename changes nothing structural — refresh the picker chip label.
5061
- if (response.reportRenamed && this.selected) {
5062
- this._refreshReportName(this.selected.id);
5946
+ if (response.reportRenamed && this.selected()) {
5947
+ this._refreshReportName(this.selected().id);
5063
5948
  }
5064
5949
  }
5065
5950
  // Drag the divider between the chat and canvas panes (pointer-capture loop
@@ -5096,16 +5981,17 @@ class ReportComponent {
5096
5981
  this._reportData.get(reportId)
5097
5982
  .pipe(takeUntilDestroyed(this._destroyRef))
5098
5983
  .subscribe((report) => {
5099
- this.report = report;
5984
+ this.report.set(report);
5985
+ // No URL state in this view: nothing is restored from the URL and
5986
+ // nothing listens to urlChanges(), so its filters stay session-only.
5100
5987
  this._filterState.init(report);
5101
5988
  this._buildReportConfig();
5102
- this._cdRef.markForCheck();
5103
5989
  });
5104
5990
  }
5105
5991
  // Report-level filter groups in their saved order — the ones that render in
5106
5992
  // the toolbar (component-level groups render on their components).
5107
5993
  _reportGroups() {
5108
- return (this.report?.filterGroups ?? [])
5994
+ return (this.report()?.filterGroups ?? [])
5109
5995
  .filter((group) => group.level === 'report' || group.level === 'both')
5110
5996
  .sort((a, b) => a.order - b.order);
5111
5997
  }
@@ -5123,7 +6009,7 @@ class ReportComponent {
5123
6009
  // The stored default is part of the signature: saving a new default in report
5124
6010
  // settings rebuilds the config, but without a key change fs-filter keeps
5125
6011
  // rendering the values it was created with until a full page reload.
5126
- groupBySignature(this.report),
6012
+ groupBySignature(this.report()),
5127
6013
  ...this._reportGroups().map((group) => `${group.id}:${group.level}:${group.label}:${defaultSignature(group)}`),
5128
6014
  ].join('|');
5129
6015
  }
@@ -5132,10 +6018,10 @@ class ReportComponent {
5132
6018
  // the picker, not here. Only built/used once a report is loaded (the template
5133
6019
  // gates it on `report`).
5134
6020
  _buildReportConfig() {
5135
- const reportId = this.report?.id ?? null;
6021
+ const reportId = this.report()?.id ?? null;
5136
6022
  // Drop a grain the control no longer offers — the agent can narrow the list
5137
6023
  // mid-session, and an override the viewer can't see is worse than none.
5138
- if (this.report && !isGranularityOffered(this.report, this._filterState.frequency())) {
6024
+ if (this.report() && !isGranularityOffered(this.report(), this._filterState.frequency())) {
5139
6025
  this._filterState.setFrequency(null);
5140
6026
  }
5141
6027
  const items = reportId
@@ -5149,7 +6035,7 @@ class ReportComponent {
5149
6035
  if (groupByItem) {
5150
6036
  items.unshift(groupByItem);
5151
6037
  }
5152
- this.reportFilterConfig = {
6038
+ this.reportFilterConfig.set({
5153
6039
  // Never touch the URL or persist filter state — report filters are
5154
6040
  // session-only, owned by ReportFilterStateService.
5155
6041
  queryParam: false,
@@ -5162,14 +6048,14 @@ class ReportComponent {
5162
6048
  // Not a filter group — the grain is read straight off the query.
5163
6049
  this._filterState.setFrequency(query?.[GROUP_BY_ITEM] || null);
5164
6050
  },
5165
- };
6051
+ });
5166
6052
  }
5167
6053
  // The report's group-by control as an fs-filter item, or null when the report
5168
6054
  // has none to show. Seeded from the session state so the grain the viewer
5169
6055
  // picked survives the report being re-fetched after a chat edit.
5170
6056
  _groupByItem() {
5171
- return this.report
5172
- ? groupByFilterItem(this.report, this._filterState.frequency())
6057
+ return this.report()
6058
+ ? groupByFilterItem(this.report(), this._filterState.frequency())
5173
6059
  : null;
5174
6060
  }
5175
6061
  _refreshReportName(reportId) {
@@ -5178,11 +6064,10 @@ class ReportComponent {
5178
6064
  .subscribe((reports) => {
5179
6065
  const renamed = reports.find((report) => report.id === reportId);
5180
6066
  if (renamed) {
5181
- this.selected = renamed;
5182
- if (this.report) {
5183
- this.report = { ...this.report, name: renamed.name };
6067
+ this.selected.set(renamed);
6068
+ if (this.report()) {
6069
+ this.report.set({ ...this.report(), name: renamed.name });
5184
6070
  }
5185
- this._cdRef.markForCheck();
5186
6071
  }
5187
6072
  });
5188
6073
  }
@@ -5197,8 +6082,8 @@ class ReportComponent {
5197
6082
  ReportExportService,
5198
6083
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
5199
6084
  // this route's chunk.
5200
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CtKYuRcT.mjs').then((module) => module.default) }),
5201
- ], 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 && editMode) {\n <!-- What layout editing can add or switch to, and the way out of it \u2014\n the same outlined buttons as the fs-ai-report toolbar. -->\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"master-toggle\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n }\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_customize\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 </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%}.master-toggle.active{background:#eceff3}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: FsAutocompleteChipsModule }, { kind: "component", type: i2$3.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$3.FsAutocompleteObjectDirective, selector: "[fsAutocompleteObject],[fsAutocompleteChipsTemplate]" }, { kind: "directive", type: i2$3.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$1.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$1.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "ready", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { 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: "component", type: ReportCanvasComponent, selector: "app-report-canvas", inputs: ["report", "editMode", "pageId"], outputs: ["componentSettings", "reportChanged", "pageChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6085
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-B9nl9XKM.mjs').then((module) => module.default) }),
6086
+ ], 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() && editMode()) {\n <!-- What layout editing can add or switch to, and the way out of it \u2014\n the same outlined buttons as the fs-ai-report toolbar. -->\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"master-toggle\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n }\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_customize\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 </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%}.master-toggle.active{background:#eceff3}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: FsAutocompleteChipsModule }, { kind: "component", type: i2$3.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$3.FsAutocompleteObjectDirective, selector: "[fsAutocompleteObject],[fsAutocompleteChipsTemplate]" }, { kind: "directive", type: i2$3.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$1.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$1.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "ready", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { 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: "component", type: ReportCanvasComponent, selector: "app-report-canvas", inputs: ["report", "editMode", "pageId"], outputs: ["componentSettings", "reportChanged", "pageChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5202
6087
  }
5203
6088
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponent, decorators: [{
5204
6089
  type: Component,
@@ -5212,7 +6097,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
5212
6097
  ReportExportService,
5213
6098
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
5214
6099
  // this route's chunk.
5215
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CtKYuRcT.mjs').then((module) => module.default) }),
6100
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-B9nl9XKM.mjs').then((module) => module.default) }),
5216
6101
  ], imports: [
5217
6102
  FormsModule,
5218
6103
  FsAutocompleteChipsModule,
@@ -5223,7 +6108,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
5223
6108
  MatIcon,
5224
6109
  MatTooltip,
5225
6110
  ReportCanvasComponent,
5226
- ], 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 && editMode) {\n <!-- What layout editing can add or switch to, and the way out of it \u2014\n the same outlined buttons as the fs-ai-report toolbar. -->\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"master-toggle\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n }\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_customize\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 </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%}.master-toggle.active{background:#eceff3}\n"] }]
6111
+ ], 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() && editMode()) {\n <!-- What layout editing can add or switch to, and the way out of it \u2014\n the same outlined buttons as the fs-ai-report toolbar. -->\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"master-toggle\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n }\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_customize\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 </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%}.master-toggle.active{background:#eceff3}\n"] }]
5227
6112
  }], ctorParameters: () => [], propDecorators: { _split: [{
5228
6113
  type: ViewChild,
5229
6114
  args: ['split', { static: true }]
@@ -5590,7 +6475,7 @@ class SemanticDatabaseComponent {
5590
6475
  .pipe(
5591
6476
  // A hard stream/transport failure (the build couldn't even start)
5592
6477
  // finalizes here — completed$ won't fire in that case.
5593
- tap({ error: (error) => this._finalizeBuild(error) }), filter((event) => event?.type === StreamEventType.Data
6478
+ tap$1({ error: (error) => this._finalizeBuild(error) }), filter((event) => event?.type === StreamEventType.Data
5594
6479
  && SemanticDatabaseComponent.LOG_KINDS.includes(event.data?.kind)
5595
6480
  && typeof event.data.line === 'string'), map((event) => event.data.line));
5596
6481
  this._process
@@ -5729,7 +6614,7 @@ class SemanticDatabaseComponent {
5729
6614
  // quietly, so the tab settles and callers never see the error.
5730
6615
  _loadStatus() {
5731
6616
  return this._reportData.semanticBuildStatus()
5732
- .pipe(tap((status) => {
6617
+ .pipe(tap$1((status) => {
5733
6618
  this.statusError.set(false);
5734
6619
  this.status.set(status);
5735
6620
  }), catchError(() => {
@@ -5816,13 +6701,13 @@ class GlobalSettingsComponent {
5816
6701
  save = () => {
5817
6702
  if (this.selectedTab === 'general') {
5818
6703
  return this._reportData.saveSettings({ semanticBuildConcurrency: this.concurrency() })
5819
- .pipe(tap((settings) => {
6704
+ .pipe(tap$1((settings) => {
5820
6705
  this._applySettings(settings);
5821
6706
  this._message.success('Settings saved');
5822
6707
  }));
5823
6708
  }
5824
6709
  return this._reportData.saveInstructions(this.content())
5825
- .pipe(tap((instructions) => {
6710
+ .pipe(tap$1((instructions) => {
5826
6711
  this._message.success('AI knowledge saved');
5827
6712
  this._dialogRef.close(instructions);
5828
6713
  }));
@@ -6398,9 +7283,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
6398
7283
  ], template: "<fs-filter [config]=\"filterConfig\"></fs-filter>\n\n<ng-container *fsSkeleton=\"nodes()\">\n <fs-tree [config]=\"treeConfig\">\n <ng-template\n fsTreeNode\n let-data=\"data\">\n <div\n class=\"fs-row.gap-sm.align-center node-row\"\n [class.deleted]=\"data.deleted\">\n <mat-icon class=\"node-icon\">\n {{ data.type === nodeType.Folder ? 'folder' : 'insert_chart_outlined' }}\n </mat-icon>\n\n @if (data.type === nodeType.Folder || data.deleted) {\n <!-- A deleted report can't be opened (the backend only assembles\n live ones, and one under a deleted folder is not on the live\n listing), so in Show Deleted mode the name is plain text \u2014\n restore it from the row menu first. -->\n <span class=\"node-name\">{{ data.name }}</span>\n } @else {\n <a\n class=\"node-name\"\n [routerLink]=\"openLink(data.report)\">\n {{ data.name }}\n </a>\n }\n\n <!-- Only on the row somebody deleted. Everything under a deleted\n folder is muted with it, but was not deleted itself. -->\n @if (data.stateDeleted) {\n <fs-chip size=\"tiny\">Deleted</fs-chip>\n }\n </div>\n </ng-template>\n </fs-tree>\n\n @if (!nodes().length) {\n <p class=\"small tree-empty\">\n No reports yet. Create one, or add a folder to group them.\n </p>\n }\n</ng-container>\n", styles: ["a{cursor:pointer}fs-tree .node>.container{flex:1;min-width:0}fs-tree .node>.container>.content{min-width:0}fs-tree .node>.container>button{margin-top:-5px}.node-row{min-width:0}.node-row.deleted{opacity:.55}.node-icon{font-size:18px;width:18px;height:18px;flex:none}.node-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-empty{margin-top:10px}\n"] }]
6399
7284
  }], ctorParameters: () => [] });
6400
7285
 
7286
+ // The report view's chat gateway: the stock one, plus fields read at the
7287
+ // moment each turn is sent and merged into that turn's body only. The chat's
7288
+ // requestData can't carry them, because it is also the key of the conversation
7289
+ // list, and a change there reloads the list and drops the open conversation.
7290
+ //
7291
+ // It relies on FsAiChatData.chatStream(basePath, body, config) being the one
7292
+ // path a turn takes. Nothing else is overridden, so the conversation list and
7293
+ // the create never see these fields. It stands in for a per-turn input on
7294
+ // fs-ai-chat itself, and can be removed when @firestitch/ai ships one.
7295
+ class ReportAiChatData extends FsAiChatData {
7296
+ // Set by the host view. A function, so it is read at send time.
7297
+ turnData = () => ({});
7298
+ chatStream(basePath, body, config = {}) {
7299
+ return super.chatStream(basePath, { ...body, ...this.turnData() }, config);
7300
+ }
7301
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportAiChatData, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
7302
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportAiChatData });
7303
+ }
7304
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportAiChatData, decorators: [{
7305
+ type: Injectable
7306
+ }] });
7307
+
6401
7308
  // A single report: the AI chat on the left, the report canvas on the right,
6402
7309
  // under an H2 title + settings gear. The host supplies [reportId] (from its
6403
- // route) and switches reports by navigating — this component never picks one.
7310
+ // route) and switches reports through its own routes — this component never
7311
+ // picks one.
6404
7312
  class FsAiReportComponent {
6405
7313
  // The report this view is focused on — supplied by the host from the route.
6406
7314
  reportId = input(null);
@@ -6410,21 +7318,35 @@ class FsAiReportComponent {
6410
7318
  // default; a host that mounts several reports on one route, or wants no URL
6411
7319
  // writes at all, sets this to null to turn it off.
6412
7320
  pageQueryParam = input('page');
7321
+ // Whether the viewer's filters, group-by grain and list sorts live in the URL
7322
+ // (g<groupId>, groupBy, s<componentId>; see report-url-state.ts), so a
7323
+ // refresh or a shared link opens the report as it was seen. On by default; a
7324
+ // host that mounts several reports on one route, or uses query keys shaped
7325
+ // like g<n> or s<n> itself, turns it off.
7326
+ urlState = input(true);
6413
7327
  // The report was deleted from its settings dialog — the host routes back to
6414
7328
  // the listing.
6415
7329
  deleted = output();
6416
7330
  // The report was renamed (settings, or the agent) — the host can refresh a
6417
7331
  // breadcrumb / document title.
6418
7332
  renamed = output();
6419
- // The viewer moved to another page. The URL is already updated by then —
6420
- // this is for hosts that track the open page for their own reasons.
7333
+ // The viewer moved to another page. This view writes the URL itself (right
7334
+ // after this fires) — this is for hosts that track the open page for their
7335
+ // own reasons.
6421
7336
  pageChanged = output();
6422
- report = null;
6423
- editMode = false;
7337
+ report = signal(null);
7338
+ editMode = signal(false);
7339
+ // The toolbar's zone indicator ('New York (EDT)'): the zone every date in
7340
+ // this report is filtered and grouped in. Empty when the server is too old
7341
+ // to send the effective zone, and the indicator is not shown.
7342
+ timezoneLabel = computed(() => {
7343
+ const effectiveTimezone = this.report()?.effectiveTimezone;
7344
+ return effectiveTimezone ? getTimezoneLabel(effectiveTimezone) : '';
7345
+ });
6424
7346
  // The page the canvas should open on, read from the URL once per report.
6425
7347
  // Cleared as soon as the canvas reports back, so a later in-app page change
6426
7348
  // is never fought by a stale value.
6427
- pageId = null;
7349
+ pageId = signal(null);
6428
7350
  // The page the chat is bound to: the one on screen. A report conversation
6429
7351
  // belongs to exactly one page, so this both scopes the conversation list and
6430
7352
  // becomes the new conversation's page. A signal because it feeds
@@ -6454,23 +7376,61 @@ class FsAiReportComponent {
6454
7376
  });
6455
7377
  // The report-level filter toolbar (fs-filter). Rebuilt whenever the loaded
6456
7378
  // report (and thus its groups) changes.
6457
- reportFilterConfig;
7379
+ reportFilterConfig = signal(null);
7380
+ // Gates the toolbar fs-filter so it isn't rendered when there is nothing to
7381
+ // put in it — no report-level filters and no group-by control.
7382
+ reportHasFilters = computed(() => {
7383
+ return this._reportGroups().length > 0 || !!this._groupByItem();
7384
+ });
7385
+ // fs-filter reads its config only once (at init). The template keys the
7386
+ // toolbar fs-filter on this signature so it's recreated (and re-reads the
7387
+ // rebuilt config) whenever the report-level filter set changes.
7388
+ reportFilterKey = computed(() => {
7389
+ return [
7390
+ // The stored default is part of the signature: saving a new default in report
7391
+ // settings rebuilds the config, but without a key change fs-filter keeps
7392
+ // rendering the values it was created with until a full page reload.
7393
+ groupBySignature(this.report()),
7394
+ ...this._reportGroups().map((group) => `${group.id}:${group.level}:${group.label}:${defaultSignature(group)}`),
7395
+ ].join('|');
7396
+ });
6458
7397
  _split;
6459
7398
  _chatPanel;
6460
7399
  // Only the canvas knows which page is on screen, and a page-scoped export
6461
7400
  // has to ask it.
6462
7401
  _canvas;
6463
7402
  _reportData = inject(ReportData);
7403
+ _reportAiChatData = inject(ReportAiChatData);
6464
7404
  _router = inject(Router);
6465
7405
  _route = inject(ActivatedRoute);
6466
7406
  _filterState = inject(ReportFilterStateService);
6467
7407
  _exportService = inject(ReportExportService);
7408
+ _collector = inject(ReportExportCollectorService);
6468
7409
  _process = inject(FsProcess);
6469
7410
  _dialog = inject(MatDialog);
6470
7411
  _destroyRef = inject(DestroyRef);
6471
- _cdRef = inject(ChangeDetectorRef);
6472
7412
  _zone = inject(NgZone);
7413
+ // The report whose URL state has been read. The URL is read only on a
7414
+ // report's first load; a reload after a chat or dialog edit keeps the state
7415
+ // in memory, which is newer than anything the URL holds.
7416
+ _restoredReportId = null;
7417
+ // Whether the canvas has reported the open page for this report. Until it
7418
+ // has, the page param in the URL is the deep link still being honored, and
7419
+ // the URL writer leaves it alone.
7420
+ _pageReported = false;
7421
+ // The canvas moved to another page. Merged into the single URL writer.
7422
+ _pageWrite$ = new Subject();
6473
7423
  constructor() {
7424
+ // Each chat turn carries what the viewer has on screen. Only the function
7425
+ // is set here; it is called when a turn is sent.
7426
+ this._reportAiChatData.turnData = () => ({ viewerState: this._chatViewerState() });
7427
+ // The ONE writer of this view's query params. Page moves and viewer-state
7428
+ // changes both funnel here, and auditTime(0) folds a burst (a bar re-sets
7429
+ // every group it shows) into a single write. Every write carries the whole
7430
+ // state, so a write that supersedes another loses nothing.
7431
+ merge(this._filterState.urlChanges(), this._pageWrite$)
7432
+ .pipe(auditTime(0), tap$1(() => this._writeUrl()), takeUntilDestroyed(this._destroyRef))
7433
+ .subscribe();
6474
7434
  // Point the shared data gateway at the configured base first (before any
6475
7435
  // load below reads it).
6476
7436
  effect(() => {
@@ -6479,27 +7439,32 @@ class FsAiReportComponent {
6479
7439
  // (Re)load whenever the host switches the report id.
6480
7440
  effect(() => {
6481
7441
  const reportId = this.reportId();
6482
- this.editMode = false;
6483
- // A new report's pages are not this report's pages — drop the binding
6484
- // until its canvas says which page it landed on.
6485
- this.chatPageId.set(null);
6486
7442
  // Seed the open page from the URL for this report. Read per load rather
6487
7443
  // than once at construction: the host may swap reports on the same route,
6488
7444
  // and each arrival carries its own ?page=.
6489
- this.pageId = this._pageIdFromUrl();
6490
- if (reportId != null) {
6491
- this._loadReport(reportId);
6492
- }
6493
- else {
6494
- this.report = null;
6495
- this._buildReportConfig();
6496
- this._cdRef.markForCheck();
6497
- }
7445
+ const pageId = this._pageIdFromUrl();
7446
+ // Only the report id and the page param re-run this. The view state below
7447
+ // is written here and read by the config build, so it stays untracked.
7448
+ untracked(() => {
7449
+ this.editMode.set(false);
7450
+ // A new report's pages are not this report's pages — drop the binding
7451
+ // until its canvas says which page it landed on.
7452
+ this.chatPageId.set(null);
7453
+ this.pageId.set(pageId);
7454
+ this._pageReported = false;
7455
+ if (reportId != null) {
7456
+ this._loadReport(reportId);
7457
+ }
7458
+ else {
7459
+ this.report.set(null);
7460
+ this._buildReportConfig();
7461
+ }
7462
+ });
6498
7463
  });
6499
7464
  this._buildReportConfig();
6500
7465
  }
6501
7466
  toggleEditMode() {
6502
- this.editMode = !this.editMode;
7467
+ this.editMode.update((editMode) => !editMode);
6503
7468
  }
6504
7469
  // The edit tools live in this toolbar, but the canvas owns what they act on
6505
7470
  // (the active page, the header/footer bands, which layer is live).
@@ -6515,12 +7480,13 @@ class FsAiReportComponent {
6515
7480
  // Report settings — name, page size, orientation, layout mode, delete. The
6516
7481
  // dialog performs the delete itself; we react to what it reports back.
6517
7482
  reportSettings() {
6518
- if (!this.report) {
7483
+ const report = this.report();
7484
+ if (!report) {
6519
7485
  return;
6520
7486
  }
6521
- const reportId = this.report.id;
7487
+ const reportId = report.id;
6522
7488
  this._dialog.open(ReportSettingsComponent, {
6523
- data: { report: this.report },
7489
+ data: { report },
6524
7490
  width: '460px',
6525
7491
  })
6526
7492
  .afterClosed()
@@ -6538,12 +7504,13 @@ class FsAiReportComponent {
6538
7504
  // Component settings — title, exact geometry, and the diagnostic views
6539
7505
  // (SQL, config, filter wiring).
6540
7506
  componentSettings(component) {
6541
- if (!this.report) {
7507
+ const report = this.report();
7508
+ if (!report) {
6542
7509
  return;
6543
7510
  }
6544
- const reportId = this.report.id;
7511
+ const reportId = report.id;
6545
7512
  const dialogRef = this._dialog.open(ComponentSettingsComponent, {
6546
- data: { report: this.report, component },
7513
+ data: { report, component },
6547
7514
  width: '640px',
6548
7515
  });
6549
7516
  // Filter add/remove/level changes persist immediately inside the dialog (no
@@ -6579,58 +7546,57 @@ class FsAiReportComponent {
6579
7546
  });
6580
7547
  }
6581
7548
  // The canvas moved to another page: reflect it in the URL so a refresh or a
6582
- // shared link comes back here. replaceUrl keeps paging out of the browser
6583
- // history — Back should leave the report, not walk every page visited.
7549
+ // shared link comes back here (the write itself is _writeUrl's).
6584
7550
  onCanvasPageChanged(pageId) {
6585
7551
  // The deep link has been consumed; from here the canvas owns the page.
6586
- this.pageId = null;
7552
+ this.pageId.set(null);
6587
7553
  this.chatPageId.set(pageId);
7554
+ this._pageReported = true;
6588
7555
  this.pageChanged.emit(pageId);
6589
- const param = this.pageQueryParam();
6590
- if (!param) {
6591
- return;
6592
- }
6593
- this._router.navigate([], {
6594
- relativeTo: this._route,
6595
- queryParams: { [param]: pageId },
6596
- queryParamsHandling: 'merge',
6597
- replaceUrl: true,
6598
- });
7556
+ this._pageWrite$.next();
6599
7557
  }
6600
7558
  // The canvas changed the report's structure (added a page) — re-fetch so the
6601
7559
  // new page renders; the canvas lands the view on it.
6602
7560
  onCanvasReportChanged() {
6603
- if (this.report) {
6604
- this._loadReport(this.report.id);
7561
+ const report = this.report();
7562
+ if (report) {
7563
+ this._loadReport(report.id);
6605
7564
  }
6606
7565
  }
6607
7566
  // Export the report to PowerPoint, entirely client-side (native editable
6608
7567
  // charts; page = slide). The dock shows progress until the file saves.
6609
7568
  exportPowerpoint() {
6610
- if (!this.report) {
7569
+ const report = this.report();
7570
+ if (!report) {
6611
7571
  return;
6612
7572
  }
6613
- this._process.run('Exporting PowerPoint', this._exportService.powerpoint(this.report));
7573
+ this._process.run('Exporting PowerPoint', this._exportService.powerpoint(report));
6614
7574
  }
6615
7575
  // Export to PDF, entirely client-side (offscreen ECharts at 3×; page = PDF page).
6616
7576
  exportPdf() {
6617
- if (!this.report) {
7577
+ const report = this.report();
7578
+ if (!report) {
6618
7579
  return;
6619
7580
  }
6620
- this._process.run('Exporting PDF', this._exportService.pdf(this.report));
7581
+ this._process.run('Exporting PDF', this._exportService.pdf(report));
6621
7582
  }
6622
- // The raw data behind the whole report in one action: the server exports every
6623
- // component's full filtered dataset and zips one CSV per component, foldered
6624
- // by page. `thisPage` narrows it to the page the canvas is showing. Unlike the
6625
- // PDF/PowerPoint exports this is entirely server-side — nothing is collected
6626
- // in the browser, so it is not capped by what a slide can hold.
7583
+ // The whole report as files in one action: the server writes one CSV per
7584
+ // component, each what the screen shows (a chart or KPI as drawn, at the
7585
+ // viewer's group-by grain; a list in full), and zips them foldered by page
7586
+ // with an Export Notes.txt that records the filters as the bar reads them.
7587
+ // Lists use their saved default sort; a header click is carried only by a
7588
+ // component's own CSV export. `thisPage` narrows it to the page the canvas is
7589
+ // showing. Unlike the PDF/PowerPoint exports the files are written
7590
+ // server-side, so they are not capped by what a slide can hold.
6627
7591
  exportData(thisPage = false) {
6628
- const report = this.report;
7592
+ const report = this.report();
6629
7593
  if (!report) {
6630
7594
  return;
6631
7595
  }
6632
7596
  const pageId = thisPage ? this._canvas?.activePage?.id ?? null : null;
6633
- this._process.download('Exporting data', this._reportData.exportReport(report.id, this._filterState.resolveForReport(report, pageId), pageId));
7597
+ const filters = this._filterState.resolveForReport(report, pageId);
7598
+ this._process.download('Exporting data', this._collector.getFilterSummary(report)
7599
+ .pipe(switchMap$1((lines) => this._reportData.exportReport(report.id, filters, pageId, this._filterState.frequency(), lines))));
6634
7600
  }
6635
7601
  // React to each chat turn (fs-chat handles the request + persistence); we
6636
7602
  // only care about the report side effects this context's backend flags.
@@ -6668,6 +7634,16 @@ class FsAiReportComponent {
6668
7634
  handle.addEventListener('pointercancel', end);
6669
7635
  });
6670
7636
  }
7637
+ // What the viewer has on screen as a chat turn sends it. The canvas
7638
+ // selection counts only in edit mode: it is drawn only then, and it is kept
7639
+ // (not cleared) when edit mode is left.
7640
+ _chatViewerState() {
7641
+ return {
7642
+ granularity: this._filterState.frequency(),
7643
+ selectedComponentId: this.editMode() ? this._canvas?.selectedComponentId() ?? null : null,
7644
+ filters: this._filterState.viewerFilters(),
7645
+ };
7646
+ }
6671
7647
  _resizeTo(clientX) {
6672
7648
  const rect = this._split.nativeElement.getBoundingClientRect();
6673
7649
  if (rect.width === 0) {
@@ -6687,50 +7663,67 @@ class FsAiReportComponent {
6687
7663
  const pageId = Number(this._route.snapshot.queryParamMap.get(param));
6688
7664
  return Number.isFinite(pageId) && pageId > 0 ? pageId : null;
6689
7665
  }
7666
+ // Writes the open page and the viewer state into the URL in one go. Only
7667
+ // the keys this view owns are touched ('merge'), and replaceUrl keeps the
7668
+ // writes out of the browser history: Back leaves the report rather than
7669
+ // walking every page and filter visited.
7670
+ _writeUrl() {
7671
+ const report = this.report();
7672
+ // A report still on screen while the host's next one loads must not write
7673
+ // its state over the new report's URL.
7674
+ if (!report || report.id !== this.reportId()) {
7675
+ return;
7676
+ }
7677
+ const queryParams = this.urlState()
7678
+ ? buildReportUrlParams(this._filterState.urlSnapshot(report), this._route.snapshot.queryParamMap)
7679
+ : {};
7680
+ const pageParam = this.pageQueryParam();
7681
+ if (pageParam && this._pageReported) {
7682
+ queryParams[pageParam] = this.chatPageId();
7683
+ }
7684
+ if (!Object.keys(queryParams).length) {
7685
+ return;
7686
+ }
7687
+ this._router.navigate([], {
7688
+ relativeTo: this._route,
7689
+ queryParams,
7690
+ queryParamsHandling: 'merge',
7691
+ replaceUrl: true,
7692
+ });
7693
+ }
6690
7694
  _loadReport(reportId, emitRenamed = false) {
6691
7695
  this._reportData.get(reportId)
6692
7696
  .pipe(takeUntilDestroyed(this._destroyRef))
6693
7697
  .subscribe((report) => {
6694
- this.report = report;
6695
- this._filterState.init(report);
7698
+ // The URL is read once per report, on its first load; every later load
7699
+ // keeps the state in memory.
7700
+ const restore = this.urlState() && reportId !== this._restoredReportId
7701
+ ? readReportUrlParams(this._route.snapshot.queryParamMap)
7702
+ : null;
7703
+ this._restoredReportId = reportId;
7704
+ this.report.set(report);
7705
+ this._filterState.init(report, restore);
6696
7706
  this._buildReportConfig();
6697
7707
  if (emitRenamed) {
6698
7708
  this.renamed.emit(report);
6699
7709
  }
6700
- this._cdRef.markForCheck();
6701
7710
  });
6702
7711
  }
6703
7712
  // Report-level filter groups in their saved order — the ones that render in
6704
7713
  // the toolbar (component-level groups render on their components).
6705
7714
  _reportGroups() {
6706
- return (this.report?.filterGroups ?? [])
7715
+ return (this.report()?.filterGroups ?? [])
6707
7716
  .filter((group) => group.level === 'report' || group.level === 'both')
6708
7717
  .sort((a, b) => a.order - b.order);
6709
7718
  }
6710
- // Gates the toolbar fs-filter so it isn't rendered when there is nothing to
6711
- // put in it — no report-level filters and no group-by control.
6712
- get reportHasFilters() {
6713
- return this._reportGroups().length > 0 || !!this._groupByItem();
6714
- }
6715
- // fs-filter reads its config only once (at init). The template keys the
6716
- // toolbar fs-filter on this signature so it's recreated (and re-reads the
6717
- // rebuilt config) whenever the report-level filter set changes.
6718
- get reportFilterKey() {
6719
- return [
6720
- // The stored default is part of the signature: saving a new default in report
6721
- // settings rebuilds the config, but without a key change fs-filter keeps
6722
- // rendering the values it was created with until a full page reload.
6723
- groupBySignature(this.report),
6724
- ...this._reportGroups().map((group) => `${group.id}:${group.level}:${group.label}:${defaultSignature(group)}`),
6725
- ].join('|');
6726
- }
6727
7719
  // (Re)build the toolbar fs-filter config — the report-level filter items and
6728
7720
  // then the group-by control.
6729
7721
  _buildReportConfig() {
6730
- const reportId = this.report?.id ?? null;
7722
+ const report = this.report();
7723
+ const reportId = report?.id ?? null;
6731
7724
  // Drop a grain the control no longer offers — the agent can narrow the list
6732
7725
  // mid-session, and an override the viewer can't see is worse than none.
6733
- if (this.report && !isGranularityOffered(this.report, this._filterState.frequency())) {
7726
+ if (report && !isGranularityOffered(report, this._filterState.frequency())) {
6734
7727
  this._filterState.setFrequency(null);
6735
7728
  }
6736
7729
  const items = reportId
@@ -6744,9 +7737,11 @@ class FsAiReportComponent {
6744
7737
  if (groupByItem) {
6745
7738
  items.unshift(groupByItem);
6746
7739
  }
6747
- this.reportFilterConfig = {
6748
- // Never touch the URL or persist filter state — report filters are
6749
- // session-only, owned by ReportFilterStateService.
7740
+ this.reportFilterConfig.set({
7741
+ // fs-filter never touches the URL or persists filter state: its keys are
7742
+ // not namespaced, it writes outside the router, and it would freeze a
7743
+ // rolling range as two dates. ReportFilterStateService owns the values,
7744
+ // and _writeUrl is the one writer of the URL.
6750
7745
  queryParam: false,
6751
7746
  persist: false,
6752
7747
  items,
@@ -6757,18 +7752,19 @@ class FsAiReportComponent {
6757
7752
  // Not a filter group — the grain is read straight off the query.
6758
7753
  this._filterState.setFrequency(query?.[GROUP_BY_ITEM] || null);
6759
7754
  },
6760
- };
7755
+ });
6761
7756
  }
6762
7757
  // The report's group-by control as an fs-filter item, or null when the report
6763
7758
  // has none to show. Seeded from the session state so the grain the viewer
6764
7759
  // picked survives the report being re-fetched after a chat edit.
6765
7760
  _groupByItem() {
6766
- return this.report
6767
- ? groupByFilterItem(this.report, this._filterState.frequency())
7761
+ const report = this.report();
7762
+ return report
7763
+ ? groupByFilterItem(report, this._filterState.frequency())
6768
7764
  : null;
6769
7765
  }
6770
7766
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FsAiReportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6771
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: FsAiReportComponent, isStandalone: true, selector: "fs-ai-report", inputs: { reportId: { classPropertyName: "reportId", publicName: "reportId", isSignal: true, isRequired: false, transformFunction: null }, basePath: { classPropertyName: "basePath", publicName: "basePath", isSignal: true, isRequired: false, transformFunction: null }, pageQueryParam: { classPropertyName: "pageQueryParam", publicName: "pageQueryParam", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { deleted: "deleted", renamed: "renamed", pageChanged: "pageChanged" }, providers: [
7767
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: FsAiReportComponent, isStandalone: true, selector: "fs-ai-report", inputs: { reportId: { classPropertyName: "reportId", publicName: "reportId", isSignal: true, isRequired: false, transformFunction: null }, basePath: { classPropertyName: "basePath", publicName: "basePath", isSignal: true, isRequired: false, transformFunction: null }, pageQueryParam: { classPropertyName: "pageQueryParam", publicName: "pageQueryParam", isSignal: true, isRequired: false, transformFunction: null }, urlState: { classPropertyName: "urlState", publicName: "urlState", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { deleted: "deleted", renamed: "renamed", pageChanged: "pageChanged" }, providers: [
6772
7768
  // Session filter state is per-open-report, not app-global; the export
6773
7769
  // services resolve filters through it, so they live at the same level.
6774
7770
  ReportFilterStateService,
@@ -6776,9 +7772,13 @@ class FsAiReportComponent {
6776
7772
  ReportPptxService,
6777
7773
  ReportPdfService,
6778
7774
  ReportExportService,
7775
+ // fs-ai-chat sits in this template, so it resolves its gateway here: the
7776
+ // report one, which adds the viewer's state to each turn it sends.
7777
+ ReportAiChatData,
7778
+ { provide: FsAiChatData, useExisting: ReportAiChatData },
6779
7779
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
6780
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CtKYuRcT.mjs').then((module) => module.default) }),
6781
- ], 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 <!-- What layout editing can add or switch to, and the way out of it.\n One style for all three \u2014 outlined, icon and text. \"Header & footer\"\n is a toggle: it keeps its label and turns gray while it is on. -->\n @if (editMode) {\n <div class=\"fs-ai-report__edit-tools fs-row.align-center.gap-xs\">\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"fs-ai-report__master\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n </div>\n }\n\n <!-- One icon for both states: it reads as pressed while editing rather\n than swapping to something else. -->\n <button\n mat-icon-button\n type=\"button\"\n class=\"fs-ai-report__toggle\"\n [class.active]=\"editMode\"\n [matTooltip]=\"editMode ? 'Done editing' : 'Edit layout'\"\n (click)=\"toggleEditMode()\">\n <mat-icon>dashboard_customize</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]=\"chatRequestData()\"\n [ready]=\"chatPageId() !== null\"\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 [pageId]=\"pageId\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\"\n (pageChanged)=\"onCanvasPageChanged($event)\">\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__toggle.active{background:#e3ecf7;color:var(--brand-primary-color, #2196f3)}.fs-ai-report__master.active{background:#eceff3}.fs-ai-report__edit-tools{margin:0 5px}.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$1.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$1.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2$1.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "ready", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3$3.MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: i3$3.MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5$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", "pageId"], outputs: ["componentSettings", "reportChanged", "pageChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7780
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-B9nl9XKM.mjs').then((module) => module.default) }),
7781
+ ], 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 <!-- What layout editing can add or switch to, and the way out of it.\n One style for all three \u2014 outlined, icon and text. \"Header & footer\"\n is a toggle: it keeps its label and turns gray while it is on. -->\n @if (editMode()) {\n <div class=\"fs-ai-report__edit-tools fs-row.align-center.gap-xs\">\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"fs-ai-report__master\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n </div>\n }\n\n <!-- The zone every date in this report is filtered and grouped in, the\n same for every viewer. Shown only when the server sends it; a click\n opens Report settings, where it is changed. -->\n @if (report()?.effectiveTimezone; as effectiveTimezone) {\n <button\n mat-button\n type=\"button\"\n class=\"fs-ai-report__timezone\"\n [matTooltip]=\"'Dates are filtered and grouped in ' + effectiveTimezone + ' for everyone. Change it in Report settings.'\"\n (click)=\"reportSettings()\">\n <mat-icon>schedule</mat-icon>\n {{ timezoneLabel() }}\n </button>\n }\n\n <!-- One icon for both states: it reads as pressed while editing rather\n than swapping to something else. -->\n <button\n mat-icon-button\n type=\"button\"\n class=\"fs-ai-report__toggle\"\n [class.active]=\"editMode()\"\n [matTooltip]=\"editMode() ? 'Done editing' : 'Edit layout'\"\n (click)=\"toggleEditMode()\">\n <mat-icon>dashboard_customize</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]=\"chatRequestData()\"\n [ready]=\"chatPageId() !== null\"\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 [pageId]=\"pageId()\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\"\n (pageChanged)=\"onCanvasPageChanged($event)\">\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__timezone{color:var(--fs-color-text-muted, rgba(0, 0, 0, .54));white-space:nowrap;flex:0 0 auto}.fs-ai-report__toggle.active{background:#e3ecf7;color:var(--brand-primary-color, #2196f3)}.fs-ai-report__master.active{background:#eceff3}.fs-ai-report__edit-tools{margin:0 5px}.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$1.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$1.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2$1.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "ready", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3$3.MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: i3$3.MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5$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", "pageId"], outputs: ["componentSettings", "reportChanged", "pageChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6782
7782
  }
6783
7783
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FsAiReportComponent, decorators: [{
6784
7784
  type: Component,
@@ -6790,8 +7790,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
6790
7790
  ReportPptxService,
6791
7791
  ReportPdfService,
6792
7792
  ReportExportService,
7793
+ // fs-ai-chat sits in this template, so it resolves its gateway here: the
7794
+ // report one, which adds the viewer's state to each turn it sends.
7795
+ ReportAiChatData,
7796
+ { provide: FsAiChatData, useExisting: ReportAiChatData },
6793
7797
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
6794
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CtKYuRcT.mjs').then((module) => module.default) }),
7798
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-B9nl9XKM.mjs').then((module) => module.default) }),
6795
7799
  ], imports: [
6796
7800
  FsFilterModule,
6797
7801
  FsMenuModule,
@@ -6800,7 +7804,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
6800
7804
  MatIconModule,
6801
7805
  MatTooltipModule,
6802
7806
  ReportCanvasComponent,
6803
- ], 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 <!-- What layout editing can add or switch to, and the way out of it.\n One style for all three \u2014 outlined, icon and text. \"Header & footer\"\n is a toggle: it keeps its label and turns gray while it is on. -->\n @if (editMode) {\n <div class=\"fs-ai-report__edit-tools fs-row.align-center.gap-xs\">\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"fs-ai-report__master\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n </div>\n }\n\n <!-- One icon for both states: it reads as pressed while editing rather\n than swapping to something else. -->\n <button\n mat-icon-button\n type=\"button\"\n class=\"fs-ai-report__toggle\"\n [class.active]=\"editMode\"\n [matTooltip]=\"editMode ? 'Done editing' : 'Edit layout'\"\n (click)=\"toggleEditMode()\">\n <mat-icon>dashboard_customize</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]=\"chatRequestData()\"\n [ready]=\"chatPageId() !== null\"\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 [pageId]=\"pageId\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\"\n (pageChanged)=\"onCanvasPageChanged($event)\">\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__toggle.active{background:#e3ecf7;color:var(--brand-primary-color, #2196f3)}.fs-ai-report__master.active{background:#eceff3}.fs-ai-report__edit-tools{margin:0 5px}.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"] }]
7807
+ ], 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 <!-- What layout editing can add or switch to, and the way out of it.\n One style for all three \u2014 outlined, icon and text. \"Header & footer\"\n is a toggle: it keeps its label and turns gray while it is on. -->\n @if (editMode()) {\n <div class=\"fs-ai-report__edit-tools fs-row.align-center.gap-xs\">\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"fs-ai-report__master\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n </div>\n }\n\n <!-- The zone every date in this report is filtered and grouped in, the\n same for every viewer. Shown only when the server sends it; a click\n opens Report settings, where it is changed. -->\n @if (report()?.effectiveTimezone; as effectiveTimezone) {\n <button\n mat-button\n type=\"button\"\n class=\"fs-ai-report__timezone\"\n [matTooltip]=\"'Dates are filtered and grouped in ' + effectiveTimezone + ' for everyone. Change it in Report settings.'\"\n (click)=\"reportSettings()\">\n <mat-icon>schedule</mat-icon>\n {{ timezoneLabel() }}\n </button>\n }\n\n <!-- One icon for both states: it reads as pressed while editing rather\n than swapping to something else. -->\n <button\n mat-icon-button\n type=\"button\"\n class=\"fs-ai-report__toggle\"\n [class.active]=\"editMode()\"\n [matTooltip]=\"editMode() ? 'Done editing' : 'Edit layout'\"\n (click)=\"toggleEditMode()\">\n <mat-icon>dashboard_customize</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]=\"chatRequestData()\"\n [ready]=\"chatPageId() !== null\"\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 [pageId]=\"pageId()\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\"\n (pageChanged)=\"onCanvasPageChanged($event)\">\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__timezone{color:var(--fs-color-text-muted, rgba(0, 0, 0, .54));white-space:nowrap;flex:0 0 auto}.fs-ai-report__toggle.active{background:#e3ecf7;color:var(--brand-primary-color, #2196f3)}.fs-ai-report__master.active{background:#eceff3}.fs-ai-report__edit-tools{margin:0 5px}.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"] }]
6804
7808
  }], ctorParameters: () => [], propDecorators: { _split: [{
6805
7809
  type: ViewChild,
6806
7810
  args: ['split', { static: true }]
@@ -6824,5 +7828,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
6824
7828
  * Generated bundle index. Do not edit.
6825
7829
  */
6826
7830
 
6827
- 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 };
6828
- //# sourceMappingURL=firestitch-report-firestitch-report-Df1IXrEs.mjs.map
7831
+ export { FsAiReportsComponent as F, GRANULARITY_OPTIONS as G, REPORT_CHART_COLORS_CSS as R, REPORT_CHART_FONT_FAMILY as a, REPORT_AXIS_LABEL_FONT_SIZE as b, ReportComponent as c, FsAiReportComponent as d, ReportData as e, ReportService as f, ReportFilterStateService as g, ReportNodeType as h, ReportState as i };
7832
+ //# sourceMappingURL=firestitch-report-firestitch-report-Bb65HEYB.mjs.map