@firestitch/report 18.11.0 → 18.12.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 (42) hide show
  1. package/app/reports/components/component-grid/component-grid.component.d.ts +29 -0
  2. package/app/reports/components/component-grid/report-grid-formatter.d.ts +9 -0
  3. package/app/reports/data/report-grid-source.d.ts +22 -0
  4. package/app/reports/data/report.data.d.ts +3 -1
  5. package/app/reports/interfaces/report-chat-viewer-state.interface.d.ts +2 -0
  6. package/app/reports/interfaces/report-chat-viewer-tile.interface.d.ts +5 -0
  7. package/app/reports/interfaces/report-grid-config.interface.d.ts +5 -0
  8. package/app/reports/interfaces/report-grid-line.interface.d.ts +6 -0
  9. package/app/reports/interfaces/report-grid-params.interface.d.ts +7 -0
  10. package/app/reports/interfaces/report-grid-report.interface.d.ts +2 -0
  11. package/app/reports/interfaces/report-grid-session-layout.interface.d.ts +5 -0
  12. package/app/reports/interfaces/report.interface.d.ts +3 -2
  13. package/app/reports/report-chat-viewer-state.d.ts +4 -1
  14. package/app/reports/report-grid-layout.d.ts +4 -0
  15. package/app/reports/services/report-filter-state.service.d.ts +6 -0
  16. package/app/reports/views/report/fs-ai-report.component.d.ts +2 -0
  17. package/esm2022/app/reports/components/component-grid/component-grid.component.mjs +93 -0
  18. package/esm2022/app/reports/components/component-grid/report-grid-formatter.mjs +94 -0
  19. package/esm2022/app/reports/components/component-text/component-text.component.mjs +1 -1
  20. package/esm2022/app/reports/components/report-component/report-component.component.mjs +21 -14
  21. package/esm2022/app/reports/data/report-grid-source.mjs +105 -0
  22. package/esm2022/app/reports/data/report.data.mjs +13 -2
  23. package/esm2022/app/reports/interfaces/report-chat-viewer-state.interface.mjs +1 -1
  24. package/esm2022/app/reports/interfaces/report-chat-viewer-tile.interface.mjs +2 -0
  25. package/esm2022/app/reports/interfaces/report-grid-config.interface.mjs +2 -0
  26. package/esm2022/app/reports/interfaces/report-grid-line.interface.mjs +2 -0
  27. package/esm2022/app/reports/interfaces/report-grid-params.interface.mjs +2 -0
  28. package/esm2022/app/reports/interfaces/report-grid-report.interface.mjs +2 -0
  29. package/esm2022/app/reports/interfaces/report-grid-session-layout.interface.mjs +2 -0
  30. package/esm2022/app/reports/interfaces/report.interface.mjs +1 -1
  31. package/esm2022/app/reports/report-chat-viewer-state.mjs +21 -6
  32. package/esm2022/app/reports/report-grid-layout.mjs +14 -0
  33. package/esm2022/app/reports/report-tile.mjs +5 -5
  34. package/esm2022/app/reports/services/report-filter-state.service.mjs +77 -11
  35. package/esm2022/app/reports/views/report/fs-ai-report.component.mjs +36 -13
  36. package/fesm2022/{firestitch-report-echarts-DKXlgu1D.mjs → firestitch-report-echarts-CoaGXKET.mjs} +2 -2
  37. package/fesm2022/{firestitch-report-echarts-DKXlgu1D.mjs.map → firestitch-report-echarts-CoaGXKET.mjs.map} +1 -1
  38. package/fesm2022/{firestitch-report-firestitch-report-Bnv62suw.mjs → firestitch-report-firestitch-report-CHPA7sla.mjs} +457 -52
  39. package/fesm2022/firestitch-report-firestitch-report-CHPA7sla.mjs.map +1 -0
  40. package/fesm2022/firestitch-report.mjs +1 -1
  41. package/package.json +4 -2
  42. package/fesm2022/firestitch-report-firestitch-report-Bnv62suw.mjs.map +0 -1
@@ -27,14 +27,16 @@ import * as i6$1 from '@firestitch/message';
27
27
  import { FsMessage, FsMessageModule } from '@firestitch/message';
28
28
  import * as i3$1 from '@firestitch/zoom-pan';
29
29
  import { FsZoomPanComponent, FsZoomPanModule } from '@firestitch/zoom-pan';
30
- import { of, forkJoin, Subject, defer, merge, map as map$1, switchMap as switchMap$1, timer, catchError as catchError$1, filter as filter$1, tap as tap$1, distinctUntilChanged, mergeMap, from, debounceTime, throwError, EMPTY, BehaviorSubject } from 'rxjs';
31
- import { FsApi, DisplayApiError, StreamEventType } from '@firestitch/api';
30
+ import { of, forkJoin, Subject, defer, merge, map as map$1, switchMap as switchMap$1, timer, catchError as catchError$1, filter as filter$1, mergeMap, EMPTY, concatWith, throwError, tap as tap$1, distinctUntilChanged, from, debounceTime, BehaviorSubject } from 'rxjs';
31
+ import { FsApi, RequestMethod, StreamEventType, DisplayApiError } from '@firestitch/api';
32
32
  import { parseISO, isValid, addDays, min, max, startOfDay, isLastDayOfMonth, endOfMonth, addMonths, addYears, addQuarters, addWeeks, formatISO, format, startOfYear, startOfQuarter, startOfMonth, subMonths, subDays, subWeeks, startOfWeek, endOfWeek } from 'date-fns';
33
33
  import { toZonedTime } from 'date-fns-tz';
34
- import { MatProgressBar } from '@angular/material/progress-bar';
34
+ import { canonicalGridLayout, GridFormat, GridUnits, GridFieldType, FsGridComponent } from '@firestitch/grid';
35
+ import { MatProgressSpinner } from '@angular/material/progress-spinner';
35
36
  import { MatCheckbox } from '@angular/material/checkbox';
36
37
  import * as i3 from '@firestitch/dialog';
37
38
  import { FsDialogModule } from '@firestitch/dialog';
39
+ import { HttpErrorResponse, HttpContext } from '@angular/common/http';
38
40
  import { NgTemplateOutlet, DOCUMENT } from '@angular/common';
39
41
  import * as i1 from '@firestitch/list';
40
42
  import { FsListComponent, PaginationStrategy, FsListModule } from '@firestitch/list';
@@ -55,7 +57,6 @@ import * as i3$2 from '@firestitch/mask';
55
57
  import { FsMaskModule } from '@firestitch/mask';
56
58
  import * as i6 from '@firestitch/tabs';
57
59
  import { FsTabsModule } from '@firestitch/tabs';
58
- import { HttpContext } from '@angular/common/http';
59
60
  import * as i9 from '@firestitch/datepicker';
60
61
  import { FsDatePickerModule, FS_DATEPICKER_CONFIG_DEFAULT, FS_DATEPICKER_CONFIG } from '@firestitch/datepicker';
61
62
  import * as i4 from '@firestitch/skeleton';
@@ -337,6 +338,17 @@ class ReportData {
337
338
  ...config,
338
339
  });
339
340
  }
341
+ // One grid tile's read (ReportGridSource): the same route as componentData,
342
+ // its body's `grid` the grid's request. The answer streams as
343
+ // newline-delimited JSON, one event per line: {report} first, then
344
+ // {grid: part} for each part of the read, a heartbeat {at} between
345
+ // statements, and the envelope with the read's summary last. The body's
346
+ // filters and state narrow the rows as they narrow any tile's; the grid's
347
+ // own column filters travel inside grid.layout. `isExport` is
348
+ // componentData's.
349
+ componentGrid(reportId, componentId, filters, state, grid, isExport = false, config = {}) {
350
+ return this._api.stream(RequestMethod.Post, this._path(`${reportId}/components/${componentId}/data`), { filters, state, export: isExport, grid }, config);
351
+ }
340
352
  // The numbers of a page's values — or the master's, for the header and
341
353
  // footer — in one request: a row per value that page reads, shown by some
342
354
  // text or read by a formula it shows (ReportPageValue). `filters` carries
@@ -4939,10 +4951,10 @@ function selectQueryValues(raw) {
4939
4951
  // read of the data. The list is positive, so a type added later (or one this
4940
4952
  // viewer does not know) is never taken for a tile. The server's twin is
4941
4953
  // ReportComponentModel::isTile().
4942
- const TILE_TYPES = ['chart', 'list', 'kpi'];
4943
- // Whether a component is a tile: a chart, a list or a KPI. Only a tile is
4944
- // drawn as one, fetched, exported as a file or counted among a filter group's
4945
- // tiles; every loop over a report's tiles reads this.
4954
+ const TILE_TYPES = ['chart', 'list', 'grid', 'kpi'];
4955
+ // Whether a component is a tile: a chart, a list, a grid or a KPI. Only a
4956
+ // tile is drawn as one, fetched, exported as a file or counted among a filter
4957
+ // group's tiles; every loop over a report's tiles reads this.
4946
4958
  function isTile(component) {
4947
4959
  return !!component && TILE_TYPES.includes(component.type);
4948
4960
  }
@@ -5954,6 +5966,19 @@ function listPriorAlias(outputs, alias, output) {
5954
5966
  : null;
5955
5967
  }
5956
5968
 
5969
+ // A table tile's layouts: the one its config authors, and the canonical key
5970
+ // two layouts are compared by.
5971
+ const NO_LAYOUT = {};
5972
+ // The layout a table tile's config authors, or none.
5973
+ function authoredGridLayout(component) {
5974
+ return component.config?.layout ?? NO_LAYOUT;
5975
+ }
5976
+ // A layout's canonical key: two layouts that draw the same table have the
5977
+ // same key, whatever order or defaults they were written with.
5978
+ function gridLayoutKey(layout) {
5979
+ return JSON.stringify(canonicalGridLayout(layout));
5980
+ }
5981
+
5957
5982
  // Whether two stored filter-group defaults are the same. Compared on the fields a
5958
5983
  // default can actually carry, so key order in the payload can't read as a change.
5959
5984
  function sameDefault(a, b) {
@@ -6054,11 +6079,11 @@ function emptyTemporaryState() {
6054
6079
  //
6055
6080
  // It is the one place the viewer's state lives while the report is open: the
6056
6081
  // group values, the group-by grain, the match, list sorts, searches and hidden
6057
- // columns, the page and each chart's drill path. None of it is ever written to
6058
- // the browser URL. It outlives a page switch (the canvas changes the page, not
6059
- // this service) and goes with the report view: a reload starts a new service,
6060
- // which opens on the report's authored defaults. changes() fires on every
6061
- // change of it.
6082
+ // columns, the page, each chart's drill path and each table's layout. None of
6083
+ // it is ever written to the browser URL. It outlives a page switch (the canvas
6084
+ // changes the page, not this service) and goes with the report view: a reload
6085
+ // starts a new service, which opens on the report's authored defaults.
6086
+ // changes() fires on every change of it.
6062
6087
  //
6063
6088
  // The durable part reads as one document: snapshot() writes it, and
6064
6089
  // differsFromDefaults says whether the screen still shows the report's
@@ -6068,7 +6093,8 @@ function emptyTemporaryState() {
6068
6093
  //
6069
6094
  // Session-only, never in the snapshot: per-filter enable/disable toggles from
6070
6095
  // a component's filter menu (the `enabled` column is the authored default; a
6071
- // viewer's toggle shouldn't write to the report).
6096
+ // viewer's toggle shouldn't write to the report), and each table's layout,
6097
+ // which fs-grid sends with the table's own reads.
6072
6098
  class ReportFilterStateService {
6073
6099
  // The open report's effective zone (resolveReportTimezone). Relative presets
6074
6100
  // resolve against today in it, and date cells render in it.
@@ -6181,6 +6207,11 @@ class ReportFilterStateService {
6181
6207
  _drills = signal(new Map());
6182
6208
  // Emits the id of the chart whose drill path changed.
6183
6209
  _drillChanged$ = new Subject();
6210
+ // The layout a viewer put each table tile in, by component id, while it
6211
+ // differs from the tile's authored layout, with the authored layout it was
6212
+ // changed from (ReportGridSessionLayout.base). A table showing its authored
6213
+ // layout has no entry, and only a table has one.
6214
+ _gridLayouts = signal(new Map());
6184
6215
  // The temporary layer: kept apart from everything above, so snapshot()
6185
6216
  // and differsFromDefaults never read it.
6186
6217
  _temporary = signal(emptyTemporaryState());
@@ -6487,6 +6518,45 @@ class ReportFilterStateService {
6487
6518
  this._stateChanged$.next();
6488
6519
  return true;
6489
6520
  }
6521
+ // The layout a table tile is on screen in: the viewer's own while they
6522
+ // changed it from the layout authored now, else the authored one. It reads
6523
+ // a signal, so a tile that shows it redraws when either moves.
6524
+ gridLayout(component) {
6525
+ return this.gridSessionLayout(component) ?? authoredGridLayout(component);
6526
+ }
6527
+ // The viewer's own layout of a table tile while it differs from the layout
6528
+ // authored now, else null: the tile shows its stored layout. The chat
6529
+ // sends it as the tile's layout.
6530
+ gridSessionLayout(component) {
6531
+ const session = this._gridLayouts().get(component.id);
6532
+ return session?.base === gridLayoutKey(authoredGridLayout(component))
6533
+ ? session.layout
6534
+ : null;
6535
+ }
6536
+ // Records the layout a viewer put a table tile in (a re-pivot, a sort, a
6537
+ // column filter). One that draws the authored layout clears the entry, so
6538
+ // the tile reads as stored again. fs-grid reads the change itself, so only
6539
+ // changes() hears about it, and only on a real change.
6540
+ setGridLayout(component, layout) {
6541
+ const base = gridLayoutKey(authoredGridLayout(component));
6542
+ const key = gridLayoutKey(layout);
6543
+ const current = this._gridLayouts().get(component.id);
6544
+ const unchanged = key === base
6545
+ ? !current
6546
+ : current?.base === base && gridLayoutKey(current.layout) === key;
6547
+ if (component.type !== 'grid' || unchanged) {
6548
+ return;
6549
+ }
6550
+ const next = new Map(this._gridLayouts());
6551
+ if (key === base) {
6552
+ next.delete(component.id);
6553
+ }
6554
+ else {
6555
+ next.set(component.id, { layout, base });
6556
+ }
6557
+ this._gridLayouts.set(next);
6558
+ this._stateChanged$.next();
6559
+ }
6490
6560
  // The durable state on screen as one document: the page, every group the
6491
6561
  // report has (null when it holds nothing, a cleared default included), the
6492
6562
  // match, the grain, and each tile's sort, search, hidden columns and drill
@@ -6850,6 +6920,7 @@ class ReportFilterStateService {
6850
6920
  this._listSearches.clear();
6851
6921
  this.listHidden.set(new Map());
6852
6922
  this._drills.set(new Map());
6923
+ this._gridLayouts.set(new Map());
6853
6924
  }
6854
6925
  // One chart's drill path; an empty path is the chart's top.
6855
6926
  _setDrillPath(componentId, path) {
@@ -6883,20 +6954,39 @@ class ReportFilterStateService {
6883
6954
  return this._tiles()
6884
6955
  .some((component) => component.id === componentId && component.type === 'chart');
6885
6956
  }
6886
- // Keeps the page and drill state to what the report (re)loaded by init()
6887
- // still has: drill paths on charts it still has, the page while it has it.
6888
- // On another report it starts again: match 'all', no drill and nothing
6889
- // temporary.
6957
+ // Drops each table layout whose tile is gone, is no table any more, or is
6958
+ // authored otherwise than when the viewer changed it.
6959
+ _pruneGridLayouts() {
6960
+ const grids = new Map(this._tiles()
6961
+ .filter((component) => component.type === 'grid')
6962
+ .map((component) => [component.id, component]));
6963
+ const kept = new Map([...this._gridLayouts()]
6964
+ .filter(([componentId, session]) => {
6965
+ const component = grids.get(componentId);
6966
+ return !!component && session.base === gridLayoutKey(authoredGridLayout(component));
6967
+ }));
6968
+ if (kept.size !== this._gridLayouts().size) {
6969
+ this._gridLayouts.set(kept);
6970
+ }
6971
+ }
6972
+ // Keeps the page, drill and table state to what the report (re)loaded by
6973
+ // init() still has: drill paths on charts it still has, the page while it
6974
+ // has it, and each table's layout while it is still a table authored as it
6975
+ // was when the viewer changed it (a new authored layout, an edit or the
6976
+ // agent's, is shown as authored). On another report it starts again: match
6977
+ // 'all', no drill, every table as authored and nothing temporary.
6890
6978
  _prunePageState(report) {
6891
6979
  if (this._report()?.id !== report.id) {
6892
6980
  this._match.set('all');
6893
6981
  this._pageId.set(null);
6894
6982
  this._drills.set(new Map());
6983
+ this._gridLayouts.set(new Map());
6895
6984
  this._temporary.set(emptyTemporaryState());
6896
6985
  }
6897
6986
  this._report.set(report);
6898
6987
  this._drills.set(new Map([...this._drills()]
6899
6988
  .filter(([componentId]) => this._isChart(componentId))));
6989
+ this._pruneGridLayouts();
6900
6990
  if (this._pageId() && !(report.pages ?? []).some((page) => page.id === this._pageId())) {
6901
6991
  this._pageId.set(null);
6902
6992
  }
@@ -8066,6 +8156,277 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
8066
8156
  ], template: "<div\n class=\"chart-host\"\n echarts\n [options]=\"options() ?? {}\"\n [theme]=\"'report'\"\n [autoResize]=\"true\"\n (chartInit)=\"onChartInit($event)\"\n (chartClick)=\"onChartClick($event)\"\n (chartLegendSelectChanged)=\"onLegendSelectChanged($event)\">\n</div>\n<app-report-component-note [data]=\"data()\"></app-report-component-note>\n", styles: [":host{display:flex;flex-direction:column;width:100%;height:100%}.chart-host{flex:1 1 0;width:100%;min-height:0}\n"] }]
8067
8157
  }] });
8068
8158
 
8159
+ // What a failed read says when the server gave no message of its own.
8160
+ const READ_FAILED = 'Failed to load data';
8161
+ // A stream that ended before its summary was cut off on the way: the parts
8162
+ // it brought may be short, so the read counts as failed.
8163
+ const STREAM_CUT = 'The table\'s answer was cut off. Try again.';
8164
+ // One grid tile's rows, read through the report: fs-grid asks, and each ask
8165
+ // is one componentGrid request. The params (ReportGridParams) are mapped onto
8166
+ // the request body's own names: the report filters go to `filters`, the 'any'
8167
+ // groups and the grain to `state`.
8168
+ //
8169
+ // The answer streams. Its report line is held in `report`, so output cells
8170
+ // format and the note strip fills from the first paint; each grid line is
8171
+ // passed on as one part, its output fields described as today's header
8172
+ // tooltip describes them (outputColumnNote); the envelope's summary is the
8173
+ // last part. A stream that ends without its summary is a failure.
8174
+ //
8175
+ // Every failure reaches the grid as an Error carrying the server's message:
8176
+ // a streamed request shows no error dialog of its own, so the grid's error
8177
+ // state is where it is read.
8178
+ class ReportGridSource {
8179
+ _reportData;
8180
+ _reportId;
8181
+ _componentId;
8182
+ // The report line of the latest read, null until one arrives.
8183
+ report = signal(null);
8184
+ _isExport;
8185
+ _locale;
8186
+ constructor(_reportData, _reportId, _componentId, options = {}) {
8187
+ this._reportData = _reportData;
8188
+ this._reportId = _reportId;
8189
+ this._componentId = _componentId;
8190
+ this._isExport = options.isExport ?? false;
8191
+ this._locale = options.locale ?? (() => REPORT_FALLBACK_LOCALE);
8192
+ }
8193
+ read(request, params) {
8194
+ return defer(() => {
8195
+ let summary = null;
8196
+ return this._stream$(request, params)
8197
+ .pipe(mergeMap((event) => {
8198
+ if (event.type === StreamEventType.Complete) {
8199
+ summary = envelopeSummary(event) ?? summary;
8200
+ return EMPTY;
8201
+ }
8202
+ return this._part$(event.data);
8203
+ }), concatWith(defer(() => (summary ? of(summary) : throwError(() => new Error(STREAM_CUT))))), catchError$1((error) => throwError(() => readError(error))));
8204
+ });
8205
+ }
8206
+ _stream$(request, params) {
8207
+ return this._reportData.componentGrid(this._reportId, this._componentId, params.filters ?? [], requestState(params), request, this._isExport);
8208
+ }
8209
+ // One data line: the report line is held, a grid line is passed on as a
8210
+ // part, and every other line is skipped.
8211
+ _part$(data) {
8212
+ const line = data;
8213
+ if (line?.report) {
8214
+ this.report.set(line.report);
8215
+ return EMPTY;
8216
+ }
8217
+ return line?.grid ? of(this._described(line.grid)) : EMPTY;
8218
+ }
8219
+ // A part whose fields say what each output holds, as today's header
8220
+ // tooltip says it. A field the report says nothing of keeps its own.
8221
+ _described(part) {
8222
+ if (!part.fields?.length) {
8223
+ return part;
8224
+ }
8225
+ const report = this.report();
8226
+ const locale = this._locale();
8227
+ return {
8228
+ ...part,
8229
+ fields: part.fields.map((field) => {
8230
+ const note = outputColumnNote(field.name, report, locale);
8231
+ return note ? { ...field, description: note } : field;
8232
+ }),
8233
+ };
8234
+ }
8235
+ }
8236
+ // The tile state the data request carries for a grid: the 'any' groups and
8237
+ // the grain, each only when set.
8238
+ function requestState(params) {
8239
+ return {
8240
+ ...(params.matchAnyGroupIds?.length ? { matchAnyGroupIds: [...params.matchAnyGroupIds] } : {}),
8241
+ ...(params.frequency ? { frequency: params.frequency } : {}),
8242
+ };
8243
+ }
8244
+ // The read's summary from the envelope that closes the stream
8245
+ // ({data: {data: {grid}}, code}), or null for the bare end-of-response event.
8246
+ function envelopeSummary(event) {
8247
+ return event.data?.data?.data?.grid ?? null;
8248
+ }
8249
+ // Any failure as an Error: the server's message when it sent one.
8250
+ function readError(error) {
8251
+ if (error instanceof HttpErrorResponse) {
8252
+ const message = error.error?.message;
8253
+ return new Error(typeof message === 'string' && message ? message : READ_FAILED);
8254
+ }
8255
+ return error instanceof Error ? error : new Error(READ_FAILED);
8256
+ }
8257
+
8258
+ const VALUE_FORMATS = {
8259
+ [GridFormat.Number]: ReportValueFormat.Number,
8260
+ [GridFormat.Percent]: ReportValueFormat.Percent,
8261
+ [GridFormat.Currency]: ReportValueFormat.Currency,
8262
+ };
8263
+ const NUMBER_UNITS = {
8264
+ [GridUnits.None]: ReportNumberUnits.None,
8265
+ [GridUnits.Thousands]: ReportNumberUnits.Thousands,
8266
+ [GridUnits.Millions]: ReportNumberUnits.Millions,
8267
+ [GridUnits.Auto]: ReportNumberUnits.Auto,
8268
+ };
8269
+ // What an abbreviated number's suffix multiplies it by, when a viewer types one.
8270
+ const UNIT_SUFFIXES = { k: 1e3, m: 1e6 };
8271
+ const YES = 'Yes';
8272
+ const NO = 'No';
8273
+ // A grid tile's formatter: every value written as the report writes it on
8274
+ // a list today. An output number reads through formatOutputValue with what
8275
+ // the report line says the output holds (a change is signed, a missing prior
8276
+ // reads as the list's dash); a plain number through formatNumber; a date in
8277
+ // the report's locale, an instant in its zone. The placement's format,
8278
+ // precision and units win over the field's.
8279
+ function reportGridFormatter(context) {
8280
+ return {
8281
+ format: (value, field, placement) => formatGridValue(context, value, field, placement),
8282
+ toNumber: (text, field, placement) => readGridNumber(context, text, field, placement),
8283
+ };
8284
+ }
8285
+ function formatGridValue(context, value, field, placement) {
8286
+ if (value === null || value === undefined || value === '') {
8287
+ return '';
8288
+ }
8289
+ switch (field.type) {
8290
+ case GridFieldType.Number:
8291
+ return formatGridNumber(context, value, field, placement);
8292
+ case GridFieldType.Date:
8293
+ case GridFieldType.Datetime:
8294
+ return formatReportDate(value, context.timezone, context.format.locale);
8295
+ case GridFieldType.Boolean:
8296
+ return value ? YES : NO;
8297
+ default:
8298
+ return optionLabel(value, field) ?? String(value);
8299
+ }
8300
+ }
8301
+ function formatGridNumber(context, value, field, placement) {
8302
+ const spec = numberSpec(field, placement);
8303
+ const output = isAggregate(placement) ? null : context.report?.outputs?.[field.name];
8304
+ return output
8305
+ ? formatOutputValue(value, output, spec, context.format).text
8306
+ : formatNumber(value, spec, context.format);
8307
+ }
8308
+ // The format, precision and units a number is written with: the placement's,
8309
+ // else the field's.
8310
+ function numberSpec(field, placement) {
8311
+ const placed = placement;
8312
+ const format = placed?.format ?? field.format;
8313
+ const precision = placed?.precision ?? field.precision;
8314
+ const units = placed?.units ?? field.units;
8315
+ return {
8316
+ ...(format ? { format: VALUE_FORMATS[format] } : {}),
8317
+ ...(typeof precision === 'number' ? { precision } : {}),
8318
+ ...(units ? { units: NUMBER_UNITS[units] } : {}),
8319
+ };
8320
+ }
8321
+ // A value placement is an aggregate of the field, not the output itself.
8322
+ function isAggregate(placement) {
8323
+ return !!placement?.aggregation;
8324
+ }
8325
+ function optionLabel(value, field) {
8326
+ return field.options?.find((option) => option.key === value)?.label ?? null;
8327
+ }
8328
+ // A number a viewer typed, read in the report's locale: its grouping dropped,
8329
+ // its decimal mark read, a K or M suffix multiplied out, and a percent of a
8330
+ // ratio-percent report read back to its ratio. null when it is no number.
8331
+ function readGridNumber(context, text, field, placement) {
8332
+ const decimal = decimalMark(context.format.locale);
8333
+ const trimmed = text.trim().toLowerCase();
8334
+ const multiplier = UNIT_SUFFIXES[trimmed.slice(-1)] ?? 1;
8335
+ const digits = trimmed
8336
+ .split(decimal).map((part) => part.replace(/[^0-9-]/g, '')).join('.');
8337
+ const number = digits && digits !== '-' && digits !== '.' ? Number(digits) * multiplier : NaN;
8338
+ if (!Number.isFinite(number)) {
8339
+ return null;
8340
+ }
8341
+ const isPercent = (placement?.format ?? field.format) === GridFormat.Percent;
8342
+ return isPercent && context.format.ratioPercent ? number / 100 : number;
8343
+ }
8344
+ function decimalMark(locale) {
8345
+ return numberFormat(locale, {}).formatToParts(1.5).find((part) => part.type === 'decimal')?.value ?? '.';
8346
+ }
8347
+
8348
+ // A grid tile: fs-grid over the tile's rows, read through ReportGridSource.
8349
+ //
8350
+ // - The params (the report filters the tile resolves to and the 'any'
8351
+ // groups) are seeded before the first render, so no read goes out without
8352
+ // them. After that a change to one of the tile's filter groups settles for
8353
+ // TILE_REFRESH_DEBOUNCE_MS, so a sweep of changes costs one read; one sent
8354
+ // under an older state version is dropped.
8355
+ // - The layout on screen is the state service's (gridLayout): the viewer's
8356
+ // own while they changed it from the layout authored now, else the
8357
+ // authored one. It outlives a page switch, and the chat reads it.
8358
+ // - A state version move (Clear filters, a saved filter) skips the wait; the
8359
+ // reset has put the authored layout back in the same tick, so it costs one
8360
+ // read.
8361
+ // - fs-grid compares the layout and params by value: a cloned value never
8362
+ // reads again.
8363
+ class ComponentGridComponent {
8364
+ reportId = input.required();
8365
+ component = input.required();
8366
+ params = signal(null);
8367
+ controls = computed(() => this.component().config?.controls ?? {});
8368
+ source = computed(() => new ReportGridSource(this._reportData, this.reportId(), this._componentId(), {
8369
+ locale: () => this._filterState.formatContext().locale,
8370
+ }));
8371
+ layout = computed(() => this._filterState.gridLayout(this.component()));
8372
+ noteData = computed(() => this.source().report());
8373
+ formatter = computed(() => reportGridFormatter({
8374
+ format: this._filterState.formatContext(),
8375
+ timezone: this._filterState.timezone(),
8376
+ report: this.source().report(),
8377
+ }));
8378
+ _componentId = computed(() => this.component().id);
8379
+ _reportData = inject(ReportData);
8380
+ _filterState = inject(ReportFilterStateService);
8381
+ _destroyRef = inject(DestroyRef);
8382
+ // The state version the params on screen were read under.
8383
+ _paramsVersion = 0;
8384
+ ngOnInit() {
8385
+ this._init();
8386
+ }
8387
+ setLayout(layout) {
8388
+ this._filterState.setGridLayout(this.component(), layout);
8389
+ }
8390
+ _init() {
8391
+ this._paramsVersion = this._filterState.stateVersion();
8392
+ this.params.set(this._readParams());
8393
+ this._filterState.changesFor(this.component())
8394
+ .pipe(switchMap$1(() => this._settled$()), tap$1(() => this._applyParams()), takeUntilDestroyed(this._destroyRef))
8395
+ .subscribe();
8396
+ }
8397
+ // A change, once it has settled: at once when the state version moved,
8398
+ // else after the debounce; dropped when the version moves while it waits,
8399
+ // since that move brings its own change.
8400
+ _settled$() {
8401
+ const version = this._filterState.stateVersion();
8402
+ const moved = version !== this._paramsVersion;
8403
+ return (moved ? of(0) : timer(TILE_REFRESH_DEBOUNCE_MS))
8404
+ .pipe(filter$1(() => this._filterState.stateVersion() === version), map$1(() => undefined));
8405
+ }
8406
+ _applyParams() {
8407
+ this._paramsVersion = this._filterState.stateVersion();
8408
+ this.params.set(this._readParams());
8409
+ }
8410
+ // The tile's report filters and 'any' groups as they stand.
8411
+ _readParams() {
8412
+ const component = this.component();
8413
+ const matchAnyGroupIds = this._filterState.tileState(component).matchAnyGroupIds;
8414
+ return {
8415
+ filters: this._filterState.resolveForComponent(component),
8416
+ ...(matchAnyGroupIds?.length ? { matchAnyGroupIds } : {}),
8417
+ };
8418
+ }
8419
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentGridComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
8420
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentGridComponent, isStandalone: true, selector: "app-report-component-grid", inputs: { reportId: { classPropertyName: "reportId", publicName: "reportId", isSignal: true, isRequired: true, transformFunction: null }, component: { classPropertyName: "component", publicName: "component", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: "<!-- The comparison and window note strip above the table, from the report\n line of the latest read; the windows' descriptions and notes go in each\n column's \"About this column\" instead. -->\n<div class=\"fs-column grid-pane\">\n <app-report-component-note\n [data]=\"noteData()\"\n [windowNotes]=\"false\">\n </app-report-component-note>\n <!-- The params are seeded before the first render, so the grid's first read\n carries the tile's report filters. -->\n @if (params(); as params) {\n <fs-grid\n class=\"fs-flex\"\n [source]=\"source()\"\n [layout]=\"layout()\"\n [params]=\"params\"\n [controls]=\"controls()\"\n [formatter]=\"formatter()\"\n (layoutChange)=\"setLayout($event)\">\n </fs-grid>\n }\n</div>\n", styles: [":host{display:block;height:100%}.grid-pane{height:100%;min-height:0}fs-grid{min-height:0}\n"], dependencies: [{ kind: "component", type: FsGridComponent, selector: "fs-grid", inputs: ["source", "layout", "controls", "params", "expansion", "panel", "editorOpen", "formatter", "clearedFilter"], outputs: ["layoutChange", "expansionChange", "panelChange", "editorOpenChange"] }, { kind: "component", type: ComponentNoteComponent, selector: "app-report-component-note", inputs: ["data", "windows", "windowDescriptions", "windowNotes"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
8421
+ }
8422
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentGridComponent, decorators: [{
8423
+ type: Component,
8424
+ args: [{ selector: 'app-report-component-grid', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
8425
+ FsGridComponent,
8426
+ ComponentNoteComponent,
8427
+ ], template: "<!-- The comparison and window note strip above the table, from the report\n line of the latest read; the windows' descriptions and notes go in each\n column's \"About this column\" instead. -->\n<div class=\"fs-column grid-pane\">\n <app-report-component-note\n [data]=\"noteData()\"\n [windowNotes]=\"false\">\n </app-report-component-note>\n <!-- The params are seeded before the first render, so the grid's first read\n carries the tile's report filters. -->\n @if (params(); as params) {\n <fs-grid\n class=\"fs-flex\"\n [source]=\"source()\"\n [layout]=\"layout()\"\n [params]=\"params\"\n [controls]=\"controls()\"\n [formatter]=\"formatter()\"\n (layoutChange)=\"setLayout($event)\">\n </fs-grid>\n }\n</div>\n", styles: [":host{display:block;height:100%}.grid-pane{height:100%;min-height:0}fs-grid{min-height:0}\n"] }]
8428
+ }] });
8429
+
8069
8430
  // A KPI's trend over its last periods: a line that stretches to the card's
8070
8431
  // width, broken where a period has no value, with the last point marked
8071
8432
  // (hollow while its period is still running). The markers are drawn over the
@@ -9585,7 +9946,7 @@ class ComponentTextComponent {
9585
9946
  }
9586
9947
  }
9587
9948
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentTextComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
9588
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentTextComponent, isStandalone: true, selector: "app-report-component-text", inputs: { html: { classPropertyName: "html", publicName: "html", isSignal: true, isRequired: false, transformFunction: null }, tokens: { classPropertyName: "tokens", publicName: "tokens", isSignal: true, isRequired: true, transformFunction: null }, editing: { classPropertyName: "editing", publicName: "editing", isSignal: true, isRequired: false, transformFunction: null }, shownElsewhere: { classPropertyName: "shownElsewhere", publicName: "shownElsewhere", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { saved: "saved", editEnded: "editEnded", valueSettings: "valueSettings" }, host: { listeners: { "keydown": "onKeydown($event)" }, properties: { "style.--text-size": "typeScale.body", "style.--text-h1-size": "typeScale.h1", "style.--text-h2-size": "typeScale.h2", "style.--text-h3-size": "typeScale.h3", "style.--text-color": "typeScale.color", "style.--text-table-border": "tableLook.border", "style.--text-table-padding": "tableLook.padding", "style.--text-table-margin": "tableLook.margin" } }, viewQueries: [{ propertyName: "_editor", first: true, predicate: FsHtmlEditorComponent, descendants: true, isSignal: true }, { propertyName: "_valuePopoverAnchor", first: true, predicate: ["valuePopoverAnchor"], descendants: true, read: ElementRef, isSignal: true }], ngImport: i0, template: "@if (editing()) {\n <!-- Floating fs-html-editor: no frame, Froala's inline toolbar pops up over\n the text. pointerdown stays here so a press in the text never starts a\n drag of the block or a pan of the canvas. -->\n <fs-html-editor\n class=\"text-content editing\"\n [config]=\"editorConfig\"\n [(ngModel)]=\"draft\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.stopPropagation()\">\n </fs-html-editor>\n\n <!-- One notice at a time: an edit stopped because it would have removed\n values, an edit that left values no text reads, or a refused command.\n mousedown keeps the editor's focus and selection, so Undo reaches it. -->\n @if (notice(); as current) {\n <div\n class=\"value-notice fs-row.gap-sm\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.preventDefault()\">\n @switch (current.kind) {\n @case ('blocked') {\n @if (current.block.removeSelection) {\n <span>\n This selection includes {{ current.block.count }} {{ current.block.count === 1 ? 'value' : 'values' }}.\n </span>\n <button\n mat-button\n color=\"primary\"\n type=\"button\"\n (click)=\"removeBlockedSelection()\">\n Remove text and {{ current.block.count === 1 ? 'value' : current.block.count + ' values' }}\n </button>\n } @else {\n <span>To take a value out, select it, then press Delete.</span>\n }\n }\n @case ('unused') {\n <span>{{ current.count }} {{ current.count === 1 ? 'value' : 'values' }} moved to Not used</span>\n <button\n mat-button\n color=\"primary\"\n type=\"button\"\n (click)=\"undoValuesUnused()\">\n Undo\n </button>\n }\n @case ('message') {\n <span>{{ current.text }}</span>\n }\n }\n <button\n mat-button\n type=\"button\"\n (click)=\"dismissNotice()\">\n Dismiss\n </button>\n </div>\n }\n} @else {\n <div\n class=\"text-content\"\n [innerHTML]=\"displayHtml()\">\n </div>\n}\n\n<!-- A clicked value chip's popover: an fs-popover on an invisible anchor laid\n over the chip (the chip itself belongs to the editor). Outside the editing\n block, so a popover still open when editing ends closes the usual way. -->\n<span\n #valuePopoverAnchor\n class=\"value-popover-anchor\"\n aria-hidden=\"true\"\n fsPopover\n trigger=\"click\"\n wrapperClass=\"value-popover-wrapper\"\n [template]=\"valuePopoverTemplate\"\n [indication]=\"false\"\n [maxWidth]=\"320\"\n [style.left.px]=\"valuePopover()?.left\"\n [style.top.px]=\"valuePopover()?.top\"\n [style.width.px]=\"valuePopover()?.width\"\n [style.height.px]=\"valuePopover()?.height\">\n</span>\n<ng-template\n #valuePopoverTemplate\n let-popover=\"popover\">\n <!-- What the value is, and its settings (where it can also be deleted); a\n chip naming no value on the page has no settings. -->\n <div class=\"value-popover fs-row\">\n <div class=\"fs-flex\">\n <div class=\"value-popover-name\">{{ valuePopoverValue()?.name ?? valuePopover()?.key }}</div>\n @if (!valuePopoverValue()) {\n <div class=\"small\">Not a value on this page</div>\n }\n </div>\n @if (valuePopoverValue()) {\n <button\n mat-icon-button\n type=\"button\"\n class=\"value-popover-settings\"\n matTooltip=\"Settings\"\n (click)=\"openValueSettings(popover)\">\n <mat-icon>settings</mat-icon>\n </button>\n }\n </div>\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:block;position:relative;width:100%;height:100%}.text-content{display:block;width:100%;height:100%;overflow:hidden;font-family:Arial,Helvetica,sans-serif;font-size:var(--text-size);line-height:1.25;color:var(--text-color);word-wrap:break-word;letter-spacing:normal;word-spacing:normal;font-weight:400;font-style:normal;text-transform:none;white-space:normal}.text-content ::ng-deep p,.text-content ::ng-deep h1,.text-content ::ng-deep h2,.text-content ::ng-deep h3,.text-content ::ng-deep ul,.text-content ::ng-deep ol{margin:0}.text-content ::ng-deep h1{font-size:var(--text-h1-size)}.text-content ::ng-deep h2{font-size:var(--text-h2-size)}.text-content ::ng-deep h3{font-size:var(--text-h3-size)}.text-content ::ng-deep h1,.text-content ::ng-deep h2,.text-content ::ng-deep h3{font-weight:700;line-height:1.2}.text-content ::ng-deep ul,.text-content ::ng-deep ol{padding-left:1.4em}.text-content ::ng-deep p:empty:before{content:\"\\200b\"}.text-content ::ng-deep a{color:inherit}.text-content ::ng-deep table{border-collapse:collapse;margin:var(--text-table-margin) 0}.text-content ::ng-deep th,.text-content ::ng-deep td{border:var(--text-table-border);padding:var(--text-table-padding);vertical-align:middle;text-align:left}.text-content ::ng-deep th{text-align:center;font-weight:700}.text-content.editing{overflow:auto;cursor:text}.text-content.editing ::ng-deep [data-embed-key],.text-content.editing ::ng-deep [data-embed-field]{padding:0 2px;border-radius:3px;background-color:#4e79a729;-webkit-user-select:all;user-select:all}.text-content.editing ::ng-deep [data-embed-key]{cursor:pointer}.text-content.editing ::ng-deep [data-embed-key]:hover{background-color:#4e79a747}.text-content.editing ::ng-deep [data-embed-missing]{background-color:#e1575924;outline:1px dashed rgba(225,87,89,.7)}.text-content.editing ::ng-deep th,.text-content.editing ::ng-deep tfoot td{background-color:transparent}.text-content.editing ::ng-deep .fr-table-selector{display:none}.text-content.editing ::ng-deep .fr-table-selection-hover{outline:none}.value-popover-anchor{position:absolute;pointer-events:none}::ng-deep .value-popover-wrapper .popover-content{padding:15px!important}.value-popover{gap:15px}.value-popover-name{font-weight:600}.value-popover-settings{--mdc-icon-button-state-layer-size: 24px;width:24px;height:24px;padding:0}.value-notice{position:absolute;top:100%;left:0;z-index:2;margin-top:4px;padding:2px 4px 2px 10px;border:1px solid #d9dee3;border-radius:4px;background:#fff;white-space:nowrap;font-family:Arial,Helvetica,sans-serif}\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: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: MatIconButton, selector: "button[mat-icon-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: FsHtmlEditorComponent, selector: "fs-html-editor", inputs: ["config", "disabled"] }, { kind: "directive", type: FsPopoverDirective, selector: "[fsPopover]", inputs: ["text", "template", "data", "leaveDelay", "showDelay", "maxWidth", "maxHeight", "wrapperClass", "autoShow", "autoClose", "loadingDiameter", "loading", "indication", "position", "theme", "size", "trigger", "enabled"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
9949
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentTextComponent, isStandalone: true, selector: "app-report-component-text", inputs: { html: { classPropertyName: "html", publicName: "html", isSignal: true, isRequired: false, transformFunction: null }, tokens: { classPropertyName: "tokens", publicName: "tokens", isSignal: true, isRequired: true, transformFunction: null }, editing: { classPropertyName: "editing", publicName: "editing", isSignal: true, isRequired: false, transformFunction: null }, shownElsewhere: { classPropertyName: "shownElsewhere", publicName: "shownElsewhere", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { saved: "saved", editEnded: "editEnded", valueSettings: "valueSettings" }, host: { listeners: { "keydown": "onKeydown($event)" }, properties: { "style.--text-size": "typeScale.body", "style.--text-h1-size": "typeScale.h1", "style.--text-h2-size": "typeScale.h2", "style.--text-h3-size": "typeScale.h3", "style.--text-color": "typeScale.color", "style.--text-table-border": "tableLook.border", "style.--text-table-padding": "tableLook.padding", "style.--text-table-margin": "tableLook.margin" } }, viewQueries: [{ propertyName: "_editor", first: true, predicate: FsHtmlEditorComponent, descendants: true, isSignal: true }, { propertyName: "_valuePopoverAnchor", first: true, predicate: ["valuePopoverAnchor"], descendants: true, read: ElementRef, isSignal: true }], ngImport: i0, template: "@if (editing()) {\n <!-- Floating fs-html-editor: no frame, Froala's inline toolbar pops up over\n the text. pointerdown stays here so a press in the text never starts a\n drag of the block or a pan of the canvas. -->\n <fs-html-editor\n class=\"text-content editing\"\n [config]=\"editorConfig\"\n [(ngModel)]=\"draft\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.stopPropagation()\">\n </fs-html-editor>\n\n <!-- One notice at a time: an edit stopped because it would have removed\n values, an edit that left values no text reads, or a refused command.\n mousedown keeps the editor's focus and selection, so Undo reaches it. -->\n @if (notice(); as current) {\n <div\n class=\"value-notice fs-row.gap-sm\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"$event.preventDefault()\">\n @switch (current.kind) {\n @case ('blocked') {\n @if (current.block.removeSelection) {\n <span>\n This selection includes {{ current.block.count }} {{ current.block.count === 1 ? 'value' : 'values' }}.\n </span>\n <button\n mat-button\n color=\"primary\"\n type=\"button\"\n (click)=\"removeBlockedSelection()\">\n Remove text and {{ current.block.count === 1 ? 'value' : current.block.count + ' values' }}\n </button>\n } @else {\n <span>To take a value out, select it, then press Delete.</span>\n }\n }\n @case ('unused') {\n <span>{{ current.count }} {{ current.count === 1 ? 'value' : 'values' }} moved to Not used</span>\n <button\n mat-button\n color=\"primary\"\n type=\"button\"\n (click)=\"undoValuesUnused()\">\n Undo\n </button>\n }\n @case ('message') {\n <span>{{ current.text }}</span>\n }\n }\n <button\n mat-button\n type=\"button\"\n (click)=\"dismissNotice()\">\n Dismiss\n </button>\n </div>\n }\n} @else {\n <div\n class=\"text-content\"\n [innerHTML]=\"displayHtml()\">\n </div>\n}\n\n<!-- A clicked value chip's popover: an fs-popover on an invisible anchor laid\n over the chip (the chip itself belongs to the editor). Outside the editing\n block, so a popover still open when editing ends closes the usual way. -->\n<span\n #valuePopoverAnchor\n class=\"value-popover-anchor\"\n aria-hidden=\"true\"\n fsPopover\n trigger=\"click\"\n wrapperClass=\"value-popover-wrapper\"\n [template]=\"valuePopoverTemplate\"\n [indication]=\"false\"\n [maxWidth]=\"320\"\n [style.left.px]=\"valuePopover()?.left\"\n [style.top.px]=\"valuePopover()?.top\"\n [style.width.px]=\"valuePopover()?.width\"\n [style.height.px]=\"valuePopover()?.height\">\n</span>\n<ng-template\n #valuePopoverTemplate\n let-popover=\"popover\">\n <!-- What the value is, and its settings (where it can also be deleted); a\n chip naming no value on the page has no settings. -->\n <div class=\"value-popover fs-row\">\n <div class=\"fs-flex\">\n <div class=\"value-popover-name\">{{ valuePopoverValue()?.name ?? valuePopover()?.key }}</div>\n @if (!valuePopoverValue()) {\n <div class=\"small\">Not a value on this page</div>\n }\n </div>\n @if (valuePopoverValue()) {\n <button\n mat-icon-button\n type=\"button\"\n class=\"value-popover-settings\"\n matTooltip=\"Settings\"\n (click)=\"openValueSettings(popover)\">\n <mat-icon>settings</mat-icon>\n </button>\n }\n </div>\n</ng-template>\n", styles: ["@charset \"UTF-8\";:host{display:block;position:relative;width:100%;height:100%}.text-content{display:block;width:100%;height:100%;overflow:hidden;font-family:Arial,Helvetica,sans-serif;font-size:var(--text-size);line-height:1.25;color:var(--text-color);word-wrap:break-word;letter-spacing:normal;word-spacing:normal;font-weight:400;font-style:normal;text-transform:none;white-space:normal}.text-content ::ng-deep p,.text-content ::ng-deep h1,.text-content ::ng-deep h2,.text-content ::ng-deep h3,.text-content ::ng-deep ul,.text-content ::ng-deep ol{margin:0}.text-content ::ng-deep h1{font-size:var(--text-h1-size)}.text-content ::ng-deep h2{font-size:var(--text-h2-size)}.text-content ::ng-deep h3{font-size:var(--text-h3-size)}.text-content ::ng-deep h1,.text-content ::ng-deep h2,.text-content ::ng-deep h3{font-weight:700;line-height:1.2}.text-content ::ng-deep ul,.text-content ::ng-deep ol{padding-left:1.4em}.text-content ::ng-deep p:empty:before{content:\"\\200b\"}.text-content ::ng-deep a{color:inherit}.text-content ::ng-deep table{border-collapse:collapse;margin:var(--text-table-margin) 0}.text-content ::ng-deep th,.text-content ::ng-deep td{border:var(--text-table-border);padding:var(--text-table-padding);vertical-align:middle;text-align:left}.text-content ::ng-deep th{text-align:center;font-weight:700}.text-content.editing{overflow:auto;cursor:text}.text-content.editing ::ng-deep [data-embed-key],.text-content.editing ::ng-deep [data-embed-field]{padding:0 2px;border-radius:3px;background-color:#4e79a729;-webkit-user-select:all;user-select:all}.text-content.editing ::ng-deep [data-embed-key]{cursor:pointer}.text-content.editing ::ng-deep [data-embed-key]:hover{background-color:#4e79a747}.text-content.editing ::ng-deep [data-embed-missing]{background-color:#e1575924;outline:1px dashed rgba(225,87,89,.7)}.text-content.editing ::ng-deep th,.text-content.editing ::ng-deep tfoot td{background-color:transparent}.text-content.editing ::ng-deep .fr-table-selector{display:none}.text-content.editing ::ng-deep .fr-table-selection-hover{outline:none}.value-popover-anchor{position:absolute;pointer-events:none}::ng-deep .value-popover-wrapper .popover-content{padding:15px!important}.value-popover{gap:15px}.value-popover-name{font-weight:600}.value-popover-settings{--mdc-icon-button-state-layer-size: 24px;width:24px;height:24px;padding:0}.value-notice{position:absolute;top:100%;left:0;z-index:2;margin-top:4px;padding:2px 4px 2px 10px;border:1px solid #d9dee3;border-radius:4px;background:#fff;white-space:nowrap;font-family:Arial,Helvetica,sans-serif}\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: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: MatIconButton, selector: "button[mat-icon-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: FsHtmlEditorComponent, selector: "fs-html-editor", inputs: ["config", "disabled"] }, { kind: "directive", type: FsPopoverDirective, selector: "[fsPopover]", inputs: ["text", "template", "data", "leaveDelay", "showDelay", "maxWidth", "maxHeight", "wrapperClass", "autoShow", "autoClose", "loadingDiameter", "loading", "indication", "position", "theme", "size", "trigger", "enabled", "panel"], outputs: ["closed"], exportAs: ["fsPopover"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
9589
9950
  }
9590
9951
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentTextComponent, decorators: [{
9591
9952
  type: Component,
@@ -9617,6 +9978,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
9617
9978
  const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
9618
9979
  const MIN_W = 0.6; // inches
9619
9980
  const MIN_H = 0.4; // inches
9981
+ // The tiles that read their own rows: a list through FsList paging, a grid
9982
+ // through fs-grid's source. This component loads every other tile's data.
9983
+ const SELF_READING_TYPES = ['list', 'grid'];
9620
9984
  // Two presses on a text block this close together open its editor. Detected
9621
9985
  // here rather than with a dblclick listener because the drag loop claims the
9622
9986
  // pointer on every press, and a browser is not obliged to deliver dblclick
@@ -9629,7 +9993,8 @@ const DOUBLE_PRESS_MS = 400;
9629
9993
  // snapper); everything works at any zoom (pixel deltas ÷ zoom·96 → inches).
9630
9994
  //
9631
9995
  // Charts and KPIs fetch their own data here; lists fetch inside
9632
- // ComponentListComponent through FsList paging.
9996
+ // ComponentListComponent through FsList paging, and grids inside
9997
+ // ComponentGridComponent through fs-grid's source.
9633
9998
  class ReportComponentComponent {
9634
9999
  reportId;
9635
10000
  component;
@@ -9686,8 +10051,9 @@ class ReportComponentComponent {
9686
10051
  // repaints the header and body.
9687
10052
  filterConfig = signal(null);
9688
10053
  data = signal(null);
9689
- // The shimmer: only while the tile has no data to show yet. A refetch keeps
9690
- // the data on screen and sets `refreshing`, a thin bar over the body.
10054
+ // A load shows a spinner centred on the tile. `loading` is the first load,
10055
+ // with nothing to show yet; a refetch keeps the data on screen, dimmed
10056
+ // under the spinner (`refreshing`). The title is never dimmed.
9691
10057
  loading = signal(false);
9692
10058
  refreshing = signal(false);
9693
10059
  error = signal(null);
@@ -9756,9 +10122,10 @@ class ReportComponentComponent {
9756
10122
  if (!this.isText) {
9757
10123
  this._rebuildHeaderOnNewState();
9758
10124
  }
9759
- // Lists fetch through FsList; text has nothing to fetch, and a value is
9760
- // never drawn (it reads inside text).
9761
- if (this.component.type !== 'list' && !this.isText && isDrawn(this.component)) {
10125
+ // Lists fetch through FsList and grids through fs-grid's source (each
10126
+ // reads its own rows); text has nothing to fetch, and a value is never
10127
+ // drawn (it reads inside text).
10128
+ if (!SELF_READING_TYPES.includes(this.component.type) && !this.isText && isDrawn(this.component)) {
9762
10129
  // One load stream (tileLoads): the first load at once, then one per
9763
10130
  // settled sweep of changes, each cancelling the one in flight, and an
9764
10131
  // answer sent before a reset is dropped.
@@ -10266,8 +10633,7 @@ class ReportComponentComponent {
10266
10633
  }
10267
10634
  // ----- data ----------------------------------------------------------------
10268
10635
  // One request of the load stream (tileLoads), built from the state now. The
10269
- // shimmer shows only while the tile has nothing on screen; otherwise the
10270
- // data stays and the thin bar shows.
10636
+ // spinner shows either way; a tile with data on screen keeps it, dimmed.
10271
10637
  _load() {
10272
10638
  const first = !this.data();
10273
10639
  this.loading.set(first);
@@ -10282,7 +10648,7 @@ class ReportComponentComponent {
10282
10648
  return this._reportData.componentData(this.reportId, this.component.id, filters, state);
10283
10649
  }
10284
10650
  // A failed load clears the data with its message, so the next load starts
10285
- // from the shimmer and never shows the data the failure replaced. A
10651
+ // from an empty tile and never shows the data the failure replaced. A
10286
10652
  // chart's answer gives its drill path.
10287
10653
  _loaded(result) {
10288
10654
  this.data.set(result.data);
@@ -10335,22 +10701,23 @@ class ReportComponentComponent {
10335
10701
  openAtPoint(anchor, click.pointer, this.zoom, () => this.markMenu?.openMenu(null));
10336
10702
  }
10337
10703
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
10338
- 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 }, 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 }, valueKeysElsewhere: { classPropertyName: "valueKeysElsewhere", publicName: "valueKeysElsewhere", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectComponent: "selectComponent", positionChanged: "positionChanged", openSettings: "openSettings", removed: "removed", valueSettings: "valueSettings", textSaved: "textSaved", valuesRemoved: "valuesRemoved" }, host: { properties: { "class.height-auto": "this.heightAuto" } }, viewQueries: [{ propertyName: "_text", first: true, predicate: ComponentTextComponent, descendants: true, isSignal: true }, { propertyName: "chartComponent", first: true, predicate: ComponentChartComponent, descendants: true }, { propertyName: "markMenu", first: true, predicate: ["markMenu"], descendants: true }, { propertyName: "markAnchor", first: true, predicate: ["markAnchor"], 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. The title shows its tokens filled, and a\n filter change repaints it in place (shownTitle). -->\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 <!-- While the chart is drilled the title leads its breadcrumb:\n 'Title \u203A 2026 \u203A Q3', the title as the viewer reads it\n (shownTitle). The title goes back to the top and each\n crumb to the period it names; the last is where the chart is.\n A long path is cut at its left end, so the crumbs nearest the\n chart stay in view. While the layout is edited the crumbs are\n plain text (the stylesheet; drillTo does nothing then). -->\n @if (drillCrumbs().length) {\n <span class=\"drill-path\">\n <span dir=\"ltr\">\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo(0)\"\n (keydown.enter)=\"drillTo(0)\">\n {{ shownTitle }}\n </a>\n @for (label of drillCrumbs(); track $index; let last = $last) {\n <span class=\"drill-separator\">{{ drillSeparator }}</span>\n @if (last) {\n <span>{{ label }}</span>\n } @else {\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo($index + 1)\"\n (keydown.enter)=\"drillTo($index + 1)\">\n {{ label }}\n </a>\n }\n }\n </span>\n </span>\n } @else {\n {{ shownTitle }}\n }\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 [fsMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <!-- Beside its trigger, inside the same block: an fs-menu that finds no\n trigger of its own draws one. -->\n <fs-menu #menu>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"editText()\">\n <mat-icon>edit</mat-icon>\n <span>Edit text</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"deleteText()\">\n <mat-icon>delete</mat-icon>\n <span>Delete</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillUp()\">\n <mat-icon>arrow_upward</mat-icon>\n <span>Drill up</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillTo(0)\">\n <mat-icon>vertical_align_top</mat-icon>\n <span>Back to top</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!hidesColumns\"\n (click)=\"chooseColumns()\">\n <mat-icon>view_column</mat-icon>\n <span>Columns</span>\n </ng-template>\n </fs-menu>\n }\n <div class=\"component-body\">\n <!-- A refetch keeps the data on screen under a thin bar; the shimmer\n below is for the first load only. -->\n @if (refreshing()) {\n <mat-progress-bar\n class=\"refresh-bar\"\n aria-label=\"Refreshing data\"\n [mode]=\"'indeterminate'\">\n </mat-progress-bar>\n }\n @if (isText) {\n <app-report-component-text\n [html]=\"textHtml() ?? component.config?.html ?? ''\"\n [tokens]=\"tokens()\"\n [editing]=\"textEditing()\"\n [shownElsewhere]=\"shownElsewhere\"\n (saved)=\"onTextSaved($event)\"\n (editEnded)=\"textEditing.set(false)\"\n (valueSettings)=\"valueSettings.emit($event)\">\n </app-report-component-text>\n } @else if (component.type === 'list') {\n @for (key of [listResetKey()]; track key) {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\">\n </app-report-component-list>\n }\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 {{ emptyMessage() }}\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data()\"\n (markClick)=\"onMarkClick($event)\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data()\"\n [actions]=\"tileActions()\"\n [(legendSelected)]=\"legendSelected\"\n (markClick)=\"onMarkClick($event)\"\n (markMiss)=\"onMarkMiss()\">\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<!-- The mark menu, for a click whose mark offers two or more actions. It\n opens at the click: its trigger is a zero-size anchor that\n anchor-at-point.ts moves there first. -->\n@if (!isText && !editMode) {\n <span\n #markAnchor\n [style.position]=\"'absolute'\"\n [fsMenuTriggerFor]=\"markMenu\">\n </span>\n <fs-menu #markMenu>\n @for (action of markMenuActions(); track action) {\n <ng-template\n fs-menu-item\n (click)=\"runMarkAction(action)\">\n {{ markActionLabels[action] }}\n </ng-template>\n }\n </fs-menu>\n}\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\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}", 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}.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 .drill-crumb{pointer-events:none}.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-filter .component-title .drill-path{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}.component-filter .component-title .drill-crumb{color:inherit;cursor:pointer;text-decoration:none}.component-filter .component-title .drill-crumb:hover,.component-filter .component-title .drill-crumb:focus-visible{text-decoration:underline}.component-filter .component-title .drill-separator{opacity:.6}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}.refresh-bar{position:absolute;top:0;left:0;right:0;z-index:1;pointer-events:none;--mdc-linear-progress-track-height: 2px;--mdc-linear-progress-active-indicator-height: 2px;--mat-progress-bar-track-height: 2px;--mat-progress-bar-active-indicator-height: 2px}: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: "component", type: MatProgressBar, selector: "mat-progress-bar", inputs: ["color", "value", "bufferValue", "mode"], outputs: ["animationEnd"], exportAs: ["matProgressBar"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i1$2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "directive", type: i1$2.FilterHeadingDirective, selector: "[fsFilterHeading]" }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "component", type: ComponentChartComponent, selector: "app-report-component-chart", inputs: ["component", "data", "legendSelected", "actions"], outputs: ["legendSelectedChange", "markClick", "markMiss"] }, { kind: "component", type: ComponentKpiComponent, selector: "app-report-component-kpi", inputs: ["component", "data"], outputs: ["markClick"] }, { 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", "shownElsewhere"], outputs: ["saved", "editEnded", "valueSettings"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
10704
+ 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 }, 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 }, valueKeysElsewhere: { classPropertyName: "valueKeysElsewhere", publicName: "valueKeysElsewhere", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectComponent: "selectComponent", positionChanged: "positionChanged", openSettings: "openSettings", removed: "removed", valueSettings: "valueSettings", textSaved: "textSaved", valuesRemoved: "valuesRemoved" }, host: { properties: { "class.height-auto": "this.heightAuto" } }, viewQueries: [{ propertyName: "_text", first: true, predicate: ComponentTextComponent, descendants: true, isSignal: true }, { propertyName: "chartComponent", first: true, predicate: ComponentChartComponent, descendants: true }, { propertyName: "markMenu", first: true, predicate: ["markMenu"], descendants: true }, { propertyName: "markAnchor", first: true, predicate: ["markAnchor"], 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 [class.refreshing]=\"refreshing()\"\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. The title shows its tokens filled, and a\n filter change repaints it in place (shownTitle). -->\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 <!-- While the chart is drilled the title leads its breadcrumb:\n 'Title \u203A 2026 \u203A Q3', the title as the viewer reads it\n (shownTitle). The title goes back to the top and each\n crumb to the period it names; the last is where the chart is.\n A long path is cut at its left end, so the crumbs nearest the\n chart stay in view. While the layout is edited the crumbs are\n plain text (the stylesheet; drillTo does nothing then). -->\n @if (drillCrumbs().length) {\n <span class=\"drill-path\">\n <span dir=\"ltr\">\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo(0)\"\n (keydown.enter)=\"drillTo(0)\">\n {{ shownTitle }}\n </a>\n @for (label of drillCrumbs(); track $index; let last = $last) {\n <span class=\"drill-separator\">{{ drillSeparator }}</span>\n @if (last) {\n <span>{{ label }}</span>\n } @else {\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo($index + 1)\"\n (keydown.enter)=\"drillTo($index + 1)\">\n {{ label }}\n </a>\n }\n }\n </span>\n </span>\n } @else {\n {{ shownTitle }}\n }\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 [fsMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <!-- Beside its trigger, inside the same block: an fs-menu that finds no\n trigger of its own draws one. -->\n <fs-menu #menu>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"editText()\">\n <mat-icon>edit</mat-icon>\n <span>Edit text</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"deleteText()\">\n <mat-icon>delete</mat-icon>\n <span>Delete</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillUp()\">\n <mat-icon>arrow_upward</mat-icon>\n <span>Drill up</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillTo(0)\">\n <mat-icon>vertical_align_top</mat-icon>\n <span>Back to top</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!hidesColumns\"\n (click)=\"chooseColumns()\">\n <mat-icon>view_column</mat-icon>\n <span>Columns</span>\n </ng-template>\n </fs-menu>\n }\n <div class=\"component-body\">\n @if (isText) {\n <app-report-component-text\n [html]=\"textHtml() ?? component.config?.html ?? ''\"\n [tokens]=\"tokens()\"\n [editing]=\"textEditing()\"\n [shownElsewhere]=\"shownElsewhere\"\n (saved)=\"onTextSaved($event)\"\n (editEnded)=\"textEditing.set(false)\"\n (valueSettings)=\"valueSettings.emit($event)\">\n </app-report-component-text>\n } @else if (component.type === 'list') {\n @for (key of [listResetKey()]; track key) {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\">\n </app-report-component-list>\n }\n } @else if (component.type === 'grid') {\n <app-report-component-grid\n [reportId]=\"reportId\"\n [component]=\"component\">\n </app-report-component-grid>\n } @else if (loading()) {\n <!-- Holds the tile's height for the spinner on the first load. -->\n <div class=\"component-state\"></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 {{ emptyMessage() }}\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data()\"\n (markClick)=\"onMarkClick($event)\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data()\"\n [actions]=\"tileActions()\"\n [(legendSelected)]=\"legendSelected\"\n (markClick)=\"onMarkClick($event)\"\n (markMiss)=\"onMarkMiss()\">\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 <!-- A load: a spinner centred on the whole tile. On a refetch the body and\n the menu are dimmed under it (.refreshing); the title is not. -->\n @if (loading() || refreshing()) {\n <mat-progress-spinner\n class=\"load-spinner\"\n aria-label=\"Loading data\"\n mode=\"indeterminate\"\n [diameter]=\"15\">\n </mat-progress-spinner>\n }\n</div>\n<!-- The mark menu, for a click whose mark offers two or more actions. It\n opens at the click: its trigger is a zero-size anchor that\n anchor-at-point.ts moves there first. -->\n@if (!isText && !editMode) {\n <span\n #markAnchor\n [style.position]=\"'absolute'\"\n [fsMenuTriggerFor]=\"markMenu\">\n </span>\n <fs-menu #markMenu>\n @for (action of markMenuActions(); track action) {\n <ng-template\n fs-menu-item\n (click)=\"runMarkAction(action)\">\n {{ markActionLabels[action] }}\n </ng-template>\n }\n </fs-menu>\n}\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\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}", 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}.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 .drill-crumb{pointer-events:none}.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-filter .component-title .drill-path{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}.component-filter .component-title .drill-crumb{color:inherit;cursor:pointer;text-decoration:none}.component-filter .component-title .drill-crumb:hover,.component-filter .component-title .drill-crumb:focus-visible{text-decoration:underline}.component-filter .component-title .drill-separator{opacity:.6}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}.component-box.refreshing .component-body,.component-box.refreshing .component-menu{opacity:.35;transition:opacity .15s ease}.load-spinner{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%) scale(calc(1 / var(--canvas-zoom, 1)));z-index:25;pointer-events:none}: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}.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: "component", type: MatProgressSpinner, selector: "mat-progress-spinner, mat-spinner", inputs: ["color", "mode", "value", "diameter", "strokeWidth"], exportAs: ["matProgressSpinner"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i1$2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "directive", type: i1$2.FilterHeadingDirective, selector: "[fsFilterHeading]" }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "component", type: ComponentChartComponent, selector: "app-report-component-chart", inputs: ["component", "data", "legendSelected", "actions"], outputs: ["legendSelectedChange", "markClick", "markMiss"] }, { kind: "component", type: ComponentGridComponent, selector: "app-report-component-grid", inputs: ["reportId", "component"] }, { kind: "component", type: ComponentKpiComponent, selector: "app-report-component-kpi", inputs: ["component", "data"], outputs: ["markClick"] }, { 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", "shownElsewhere"], outputs: ["saved", "editEnded", "valueSettings"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
10339
10705
  }
10340
10706
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, decorators: [{
10341
10707
  type: Component,
10342
10708
  args: [{ selector: 'app-report-component', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
10343
10709
  MatIcon,
10344
10710
  MatIconButton,
10345
- MatProgressBar,
10711
+ MatProgressSpinner,
10346
10712
  MatTooltip,
10347
10713
  FsFilterModule,
10348
10714
  FsMenuModule,
10349
10715
  ComponentChartComponent,
10716
+ ComponentGridComponent,
10350
10717
  ComponentKpiComponent,
10351
10718
  ComponentListComponent,
10352
10719
  ComponentTextComponent,
10353
- ], 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. The title shows its tokens filled, and a\n filter change repaints it in place (shownTitle). -->\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 <!-- While the chart is drilled the title leads its breadcrumb:\n 'Title \u203A 2026 \u203A Q3', the title as the viewer reads it\n (shownTitle). The title goes back to the top and each\n crumb to the period it names; the last is where the chart is.\n A long path is cut at its left end, so the crumbs nearest the\n chart stay in view. While the layout is edited the crumbs are\n plain text (the stylesheet; drillTo does nothing then). -->\n @if (drillCrumbs().length) {\n <span class=\"drill-path\">\n <span dir=\"ltr\">\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo(0)\"\n (keydown.enter)=\"drillTo(0)\">\n {{ shownTitle }}\n </a>\n @for (label of drillCrumbs(); track $index; let last = $last) {\n <span class=\"drill-separator\">{{ drillSeparator }}</span>\n @if (last) {\n <span>{{ label }}</span>\n } @else {\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo($index + 1)\"\n (keydown.enter)=\"drillTo($index + 1)\">\n {{ label }}\n </a>\n }\n }\n </span>\n </span>\n } @else {\n {{ shownTitle }}\n }\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 [fsMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <!-- Beside its trigger, inside the same block: an fs-menu that finds no\n trigger of its own draws one. -->\n <fs-menu #menu>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"editText()\">\n <mat-icon>edit</mat-icon>\n <span>Edit text</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"deleteText()\">\n <mat-icon>delete</mat-icon>\n <span>Delete</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillUp()\">\n <mat-icon>arrow_upward</mat-icon>\n <span>Drill up</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillTo(0)\">\n <mat-icon>vertical_align_top</mat-icon>\n <span>Back to top</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!hidesColumns\"\n (click)=\"chooseColumns()\">\n <mat-icon>view_column</mat-icon>\n <span>Columns</span>\n </ng-template>\n </fs-menu>\n }\n <div class=\"component-body\">\n <!-- A refetch keeps the data on screen under a thin bar; the shimmer\n below is for the first load only. -->\n @if (refreshing()) {\n <mat-progress-bar\n class=\"refresh-bar\"\n aria-label=\"Refreshing data\"\n [mode]=\"'indeterminate'\">\n </mat-progress-bar>\n }\n @if (isText) {\n <app-report-component-text\n [html]=\"textHtml() ?? component.config?.html ?? ''\"\n [tokens]=\"tokens()\"\n [editing]=\"textEditing()\"\n [shownElsewhere]=\"shownElsewhere\"\n (saved)=\"onTextSaved($event)\"\n (editEnded)=\"textEditing.set(false)\"\n (valueSettings)=\"valueSettings.emit($event)\">\n </app-report-component-text>\n } @else if (component.type === 'list') {\n @for (key of [listResetKey()]; track key) {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\">\n </app-report-component-list>\n }\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 {{ emptyMessage() }}\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data()\"\n (markClick)=\"onMarkClick($event)\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data()\"\n [actions]=\"tileActions()\"\n [(legendSelected)]=\"legendSelected\"\n (markClick)=\"onMarkClick($event)\"\n (markMiss)=\"onMarkMiss()\">\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<!-- The mark menu, for a click whose mark offers two or more actions. It\n opens at the click: its trigger is a zero-size anchor that\n anchor-at-point.ts moves there first. -->\n@if (!isText && !editMode) {\n <span\n #markAnchor\n [style.position]=\"'absolute'\"\n [fsMenuTriggerFor]=\"markMenu\">\n </span>\n <fs-menu #markMenu>\n @for (action of markMenuActions(); track action) {\n <ng-template\n fs-menu-item\n (click)=\"runMarkAction(action)\">\n {{ markActionLabels[action] }}\n </ng-template>\n }\n </fs-menu>\n}\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\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}", 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}.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 .drill-crumb{pointer-events:none}.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-filter .component-title .drill-path{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}.component-filter .component-title .drill-crumb{color:inherit;cursor:pointer;text-decoration:none}.component-filter .component-title .drill-crumb:hover,.component-filter .component-title .drill-crumb:focus-visible{text-decoration:underline}.component-filter .component-title .drill-separator{opacity:.6}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}.refresh-bar{position:absolute;top:0;left:0;right:0;z-index:1;pointer-events:none;--mdc-linear-progress-track-height: 2px;--mdc-linear-progress-active-indicator-height: 2px;--mat-progress-bar-track-height: 2px;--mat-progress-bar-active-indicator-height: 2px}: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"] }]
10720
+ ], template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.text]=\"isText\"\n [class.selected]=\"selected\"\n [class.refreshing]=\"refreshing()\"\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. The title shows its tokens filled, and a\n filter change repaints it in place (shownTitle). -->\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 <!-- While the chart is drilled the title leads its breadcrumb:\n 'Title \u203A 2026 \u203A Q3', the title as the viewer reads it\n (shownTitle). The title goes back to the top and each\n crumb to the period it names; the last is where the chart is.\n A long path is cut at its left end, so the crumbs nearest the\n chart stay in view. While the layout is edited the crumbs are\n plain text (the stylesheet; drillTo does nothing then). -->\n @if (drillCrumbs().length) {\n <span class=\"drill-path\">\n <span dir=\"ltr\">\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo(0)\"\n (keydown.enter)=\"drillTo(0)\">\n {{ shownTitle }}\n </a>\n @for (label of drillCrumbs(); track $index; let last = $last) {\n <span class=\"drill-separator\">{{ drillSeparator }}</span>\n @if (last) {\n <span>{{ label }}</span>\n } @else {\n <a\n class=\"drill-crumb\"\n tabindex=\"0\"\n (click)=\"drillTo($index + 1)\"\n (keydown.enter)=\"drillTo($index + 1)\">\n {{ label }}\n </a>\n }\n }\n </span>\n </span>\n } @else {\n {{ shownTitle }}\n }\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 [fsMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <!-- Beside its trigger, inside the same block: an fs-menu that finds no\n trigger of its own draws one. -->\n <fs-menu #menu>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"editText()\">\n <mat-icon>edit</mat-icon>\n <span>Edit text</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!isText\"\n (click)=\"deleteText()\">\n <mat-icon>delete</mat-icon>\n <span>Delete</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillUp()\">\n <mat-icon>arrow_upward</mat-icon>\n <span>Drill up</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!drillCrumbs().length || editMode\"\n (click)=\"drillTo(0)\">\n <mat-icon>vertical_align_top</mat-icon>\n <span>Back to top</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"isText\"\n (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </ng-template>\n <ng-template\n fs-menu-item\n [hidden]=\"!hidesColumns\"\n (click)=\"chooseColumns()\">\n <mat-icon>view_column</mat-icon>\n <span>Columns</span>\n </ng-template>\n </fs-menu>\n }\n <div class=\"component-body\">\n @if (isText) {\n <app-report-component-text\n [html]=\"textHtml() ?? component.config?.html ?? ''\"\n [tokens]=\"tokens()\"\n [editing]=\"textEditing()\"\n [shownElsewhere]=\"shownElsewhere\"\n (saved)=\"onTextSaved($event)\"\n (editEnded)=\"textEditing.set(false)\"\n (valueSettings)=\"valueSettings.emit($event)\">\n </app-report-component-text>\n } @else if (component.type === 'list') {\n @for (key of [listResetKey()]; track key) {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\">\n </app-report-component-list>\n }\n } @else if (component.type === 'grid') {\n <app-report-component-grid\n [reportId]=\"reportId\"\n [component]=\"component\">\n </app-report-component-grid>\n } @else if (loading()) {\n <!-- Holds the tile's height for the spinner on the first load. -->\n <div class=\"component-state\"></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 {{ emptyMessage() }}\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data()\"\n (markClick)=\"onMarkClick($event)\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data()\"\n [actions]=\"tileActions()\"\n [(legendSelected)]=\"legendSelected\"\n (markClick)=\"onMarkClick($event)\"\n (markMiss)=\"onMarkMiss()\">\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 <!-- A load: a spinner centred on the whole tile. On a refetch the body and\n the menu are dimmed under it (.refreshing); the title is not. -->\n @if (loading() || refreshing()) {\n <mat-progress-spinner\n class=\"load-spinner\"\n aria-label=\"Loading data\"\n mode=\"indeterminate\"\n [diameter]=\"15\">\n </mat-progress-spinner>\n }\n</div>\n<!-- The mark menu, for a click whose mark offers two or more actions. It\n opens at the click: its trigger is a zero-size anchor that\n anchor-at-point.ts moves there first. -->\n@if (!isText && !editMode) {\n <span\n #markAnchor\n [style.position]=\"'absolute'\"\n [fsMenuTriggerFor]=\"markMenu\">\n </span>\n <fs-menu #markMenu>\n @for (action of markMenuActions(); track action) {\n <ng-template\n fs-menu-item\n (click)=\"runMarkAction(action)\">\n {{ markActionLabels[action] }}\n </ng-template>\n }\n </fs-menu>\n}\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\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}", 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}.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 .drill-crumb{pointer-events:none}.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-filter .component-title .drill-path{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}.component-filter .component-title .drill-crumb{color:inherit;cursor:pointer;text-decoration:none}.component-filter .component-title .drill-crumb:hover,.component-filter .component-title .drill-crumb:focus-visible{text-decoration:underline}.component-filter .component-title .drill-separator{opacity:.6}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}.component-box.refreshing .component-body,.component-box.refreshing .component-menu{opacity:.35;transition:opacity .15s ease}.load-spinner{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%) scale(calc(1 / var(--canvas-zoom, 1)));z-index:25;pointer-events:none}: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}.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"] }]
10354
10721
  }], propDecorators: { reportId: [{
10355
10722
  type: Input
10356
10723
  }], component: [{
@@ -13308,7 +13675,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
13308
13675
  // in the report's format context, as on screen.
13309
13676
  async function renderChartImage(component, data, width, height, context, pixelRatio = 3) {
13310
13677
  // The same tree-shaken echarts build + 'report' theme the live canvas uses.
13311
- const echarts = (await import('./firestitch-report-echarts-DKXlgu1D.mjs')).default;
13678
+ const echarts = (await import('./firestitch-report-echarts-CoaGXKET.mjs')).default;
13312
13679
  const host = document.createElement('div');
13313
13680
  host.style.width = `${width}px`;
13314
13681
  host.style.height = `${height}px`;
@@ -16441,7 +16808,7 @@ class ReportComponent {
16441
16808
  ReportExportService,
16442
16809
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
16443
16810
  // this route's chunk.
16444
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-DKXlgu1D.mjs').then((module) => module.default) }),
16811
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CoaGXKET.mjs').then((module) => module.default) }),
16445
16812
  // The bar's date pickers start their weeks on Monday and list the built-in
16446
16813
  // presets; a preset only sets the days.
16447
16814
  provideReportDatePicker(),
@@ -16462,7 +16829,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
16462
16829
  ReportExportService,
16463
16830
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
16464
16831
  // this route's chunk.
16465
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-DKXlgu1D.mjs').then((module) => module.default) }),
16832
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CoaGXKET.mjs').then((module) => module.default) }),
16466
16833
  // The bar's date pickers start their weeks on Monday and list the built-in
16467
16834
  // presets; a preset only sets the days.
16468
16835
  provideReportDatePicker(),
@@ -17673,11 +18040,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
17673
18040
  type: Injectable
17674
18041
  }] });
17675
18042
 
17676
- // The parts of the chat's viewer state (ReportChatViewerState) read off a
17677
- // snapshot of the screen (the state service's snapshot()). Pure, so the
17678
- // node harness checks them. The caps are the server's: it reads at most
17679
- // CHAT_DRILLS_MAX drilled charts, DRILL_MAX_DEPTH steps a path and
17680
- // CHAT_LABEL_MAX characters a label, and drops the rest.
17681
18043
  const CHAT_DRILLS_MAX = 10;
17682
18044
  const CHAT_LABEL_MAX = 100;
17683
18045
  // The match the chat is told of: 'any' while the groups match any filter.
@@ -17704,6 +18066,26 @@ function chatViewerDrills(snapshot, locale) {
17704
18066
  })),
17705
18067
  }));
17706
18068
  }
18069
+ // Every table tile on screen while `pageId` is the page on screen: the
18070
+ // page's own, then the header and footer's unless the page hides them, each
18071
+ // with the layout the viewer has it in (layoutOf; null while it shows the
18072
+ // stored layout, and then left out). Empty when the page is unknown or holds
18073
+ // no table.
18074
+ function chatViewerTiles(report, pageId, layoutOf) {
18075
+ const page = (report?.pages ?? []).find((item) => item.id === pageId);
18076
+ if (!report || !page) {
18077
+ return [];
18078
+ }
18079
+ return [
18080
+ ...(page.components ?? []),
18081
+ ...(page.masterHidden ? [] : report.master?.components ?? []),
18082
+ ]
18083
+ .filter((component) => component.type === 'grid')
18084
+ .map((component) => {
18085
+ const layout = layoutOf(component);
18086
+ return layout ? { componentId: component.id, layout } : { componentId: component.id };
18087
+ });
18088
+ }
17707
18089
 
17708
18090
  // The groups a saved filter holds: the report-wide ones.
17709
18091
  function savedFilterGroups(groups) {
@@ -17804,12 +18186,12 @@ class FsAiReportComponent {
17804
18186
  reportId = input(null);
17805
18187
  // API base for the reports + chat endpoints; override to mount elsewhere.
17806
18188
  basePath = input('reports');
17807
- // The query param a report reads its opening page from (a link in a ticket
17808
- // can name a page: ?page=7). It is read once, when a report opens, and
17809
- // never written: a report keeps no state in the browser URL. Its filters,
17810
- // the page on screen and the rest live in the report's state
17811
- // (ReportFilterStateService), so a reload opens the report on its
17812
- // defaults. A host that wants no page read sets this to null.
18189
+ // The query param that carries the page on screen (?page=7). A report opens
18190
+ // on the page it names, and each move to another page writes it, in place,
18191
+ // so a refresh or a shared link comes back to the same page. It is the only
18192
+ // state a report keeps in the URL: its filters and the rest live in the
18193
+ // report's state (ReportFilterStateService). A host that mounts several
18194
+ // reports on one route, or wants no URL writes, sets this to null.
17813
18195
  pageQueryParam = input('page');
17814
18196
  // The report was deleted from its settings dialog — the host routes back to
17815
18197
  // the listing.
@@ -17893,6 +18275,7 @@ class FsAiReportComponent {
17893
18275
  _reportData = inject(ReportData);
17894
18276
  _reportAiChatData = inject(ReportAiChatData);
17895
18277
  _route = inject(ActivatedRoute);
18278
+ _router = inject(Router);
17896
18279
  _filterState = inject(ReportFilterStateService);
17897
18280
  _filterOptions = inject(ReportFilterOptionsService);
17898
18281
  _exportService = inject(ReportExportService);
@@ -18046,7 +18429,7 @@ class FsAiReportComponent {
18046
18429
  });
18047
18430
  }
18048
18431
  // The canvas moved to another page. The report-level filters stay as they
18049
- // are (they live in the state service), and the URL is left alone.
18432
+ // are (they live in the state service); the page goes into the URL.
18050
18433
  onCanvasPageChanged(pageId) {
18051
18434
  // The asked-for page has been consumed; from here the canvas owns the page.
18052
18435
  this.pageId.set(null);
@@ -18054,6 +18437,7 @@ class FsAiReportComponent {
18054
18437
  // The open page is part of the state the tiles and exports read.
18055
18438
  this._filterState.setPage(pageId);
18056
18439
  this.pageChanged.emit(pageId);
18440
+ this._writePageToUrl(pageId);
18057
18441
  // The bar shows the page groups of the page on screen.
18058
18442
  if (hasPageGroup(this.report())) {
18059
18443
  this._buildReportConfig();
@@ -18150,16 +18534,20 @@ class FsAiReportComponent {
18150
18534
  // What the viewer has on screen as a chat turn sends it. The canvas
18151
18535
  // selection counts only in edit mode: it is drawn only then, and it is kept
18152
18536
  // (not cleared) when edit mode is left. The match is sent only while it is
18153
- // 'any', and the drilled charts only when one is drilled.
18537
+ // 'any', the drilled charts only when one is drilled, and the tables only
18538
+ // when the bound page shows one, each with the layout the viewer has it in
18539
+ // while that differs from the stored one.
18154
18540
  _chatViewerState() {
18155
18541
  const snapshot = this._filterState.snapshot();
18156
18542
  const drills = chatViewerDrills(snapshot, this._filterState.formatContext().locale);
18543
+ const tiles = chatViewerTiles(this._filterState.report(), this.chatPageId(), (component) => this._filterState.gridSessionLayout(component));
18157
18544
  return {
18158
18545
  granularity: this._filterState.frequency(),
18159
18546
  selectedComponentId: this.editMode() ? this._canvas?.selectedComponentId() ?? null : null,
18160
18547
  filters: this._filterState.viewerFilters(),
18161
18548
  ...chatViewerMatch(snapshot),
18162
18549
  ...(drills.length ? { drills } : {}),
18550
+ ...(tiles.length ? { tiles } : {}),
18163
18551
  };
18164
18552
  }
18165
18553
  _resizeTo(clientX) {
@@ -18171,8 +18559,25 @@ class FsAiReportComponent {
18171
18559
  const clamped = Math.min(0.8, Math.max(0.2, ratio));
18172
18560
  this._chatPanel.nativeElement.style.flexBasis = `${clamped * 100}%`;
18173
18561
  }
18174
- // The page id the URL holds right now (?page=, read only), or null when the
18175
- // page param is off or the value is not a usable id.
18562
+ // The page on screen into the URL (?page=), replacing the current history
18563
+ // entry rather than adding one, so Back leaves the report instead of
18564
+ // stepping through its pages. Only the page param is touched; the host's
18565
+ // own query params stay. Nothing is written when the param is off, or when
18566
+ // the URL already names the page.
18567
+ _writePageToUrl(pageId) {
18568
+ const param = this.pageQueryParam();
18569
+ if (!param || !pageId || this._pageIdFromUrl() === pageId) {
18570
+ return;
18571
+ }
18572
+ this._router.navigate([], {
18573
+ relativeTo: this._route,
18574
+ queryParams: { [param]: pageId },
18575
+ queryParamsHandling: 'merge',
18576
+ replaceUrl: true,
18577
+ });
18578
+ }
18579
+ // The page id the URL holds right now (?page=), or null when the page param
18580
+ // is off or the value is not a usable id.
18176
18581
  _pageIdFromUrl() {
18177
18582
  const param = this.pageQueryParam();
18178
18583
  if (!param) {
@@ -18400,7 +18805,7 @@ class FsAiReportComponent {
18400
18805
  ReportAiChatData,
18401
18806
  { provide: FsAiChatData, useExisting: ReportAiChatData },
18402
18807
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
18403
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-DKXlgu1D.mjs').then((module) => module.default) }),
18808
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CoaGXKET.mjs').then((module) => module.default) }),
18404
18809
  // The bar's date pickers start their weeks on Monday and list the built-in
18405
18810
  // presets; a preset only sets the days.
18406
18811
  provideReportDatePicker(),
@@ -18424,7 +18829,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
18424
18829
  ReportAiChatData,
18425
18830
  { provide: FsAiChatData, useExisting: ReportAiChatData },
18426
18831
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
18427
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-DKXlgu1D.mjs').then((module) => module.default) }),
18832
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CoaGXKET.mjs').then((module) => module.default) }),
18428
18833
  // The bar's date pickers start their weeks on Monday and list the built-in
18429
18834
  // presets; a preset only sets the days.
18430
18835
  provideReportDatePicker(),
@@ -18464,4 +18869,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
18464
18869
  */
18465
18870
 
18466
18871
  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, ReportValueFormat as j, ReportValueFormula as k, ReportListColumnFormat as l, ReportNumberUnits as m, ReportChartDataLabels as n, ReportChartColor as o, ReportChartLineStyle as p, ReportChartMarkStat as q, ReportChartDateLabel as r, ReportChangePolarity as s };
18467
- //# sourceMappingURL=firestitch-report-firestitch-report-Bnv62suw.mjs.map
18872
+ //# sourceMappingURL=firestitch-report-firestitch-report-CHPA7sla.mjs.map