@firestitch/report 18.12.0 → 18.13.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 (40) hide show
  1. package/app/reports/components/component-grid/component-grid.component.d.ts +5 -1
  2. package/app/reports/components/component-grid/report-grid-cell.pipe.d.ts +11 -0
  3. package/app/reports/components/report-component/report-component.component.d.ts +0 -1
  4. package/app/reports/export/report-export-collector.service.d.ts +8 -1
  5. package/app/reports/export/report-filter-lines.d.ts +2 -0
  6. package/app/reports/export/report-grid-export.d.ts +20 -0
  7. package/app/reports/export/report-pdf.service.d.ts +2 -0
  8. package/app/reports/export/report-pptx.service.d.ts +1 -0
  9. package/app/reports/group-by.d.ts +3 -0
  10. package/app/reports/interfaces/report-export-snapshot.interface.d.ts +2 -0
  11. package/app/reports/interfaces/report.interface.d.ts +7 -0
  12. package/app/reports/report-filter-items.d.ts +1 -1
  13. package/app/reports/report-grid-layout.d.ts +8 -2
  14. package/app/reports/services/report-filter-state.service.d.ts +8 -1
  15. package/app/reports/tile-data-state.d.ts +2 -2
  16. package/esm2022/app/reports/components/component-grid/component-grid.component.mjs +34 -7
  17. package/esm2022/app/reports/components/component-grid/report-grid-cell.pipe.mjs +41 -0
  18. package/esm2022/app/reports/components/report-component/report-component.component.mjs +15 -19
  19. package/esm2022/app/reports/data/report.data.mjs +3 -3
  20. package/esm2022/app/reports/export/report-export-collector.service.mjs +72 -7
  21. package/esm2022/app/reports/export/report-filter-lines.mjs +68 -1
  22. package/esm2022/app/reports/export/report-grid-export.mjs +48 -0
  23. package/esm2022/app/reports/export/report-pdf.service.mjs +47 -17
  24. package/esm2022/app/reports/export/report-pptx.service.mjs +48 -4
  25. package/esm2022/app/reports/group-by.mjs +13 -0
  26. package/esm2022/app/reports/interfaces/report-export-snapshot.interface.mjs +1 -1
  27. package/esm2022/app/reports/interfaces/report.interface.mjs +1 -1
  28. package/esm2022/app/reports/report-filter-items.mjs +9 -7
  29. package/esm2022/app/reports/report-grid-layout.mjs +57 -4
  30. package/esm2022/app/reports/services/report-filter-state.service.mjs +124 -38
  31. package/esm2022/app/reports/tile-data-state.mjs +12 -17
  32. package/esm2022/app/reports/views/report/fs-ai-report.component.mjs +2 -2
  33. package/esm2022/app/reports/views/report/report.component.mjs +2 -2
  34. package/fesm2022/{firestitch-report-echarts-CoaGXKET.mjs → firestitch-report-echarts-EecIloKo.mjs} +2 -2
  35. package/fesm2022/{firestitch-report-echarts-CoaGXKET.mjs.map → firestitch-report-echarts-EecIloKo.mjs.map} +1 -1
  36. package/fesm2022/{firestitch-report-firestitch-report-CHPA7sla.mjs → firestitch-report-firestitch-report-DbdLxQSE.mjs} +566 -116
  37. package/fesm2022/firestitch-report-firestitch-report-DbdLxQSE.mjs.map +1 -0
  38. package/fesm2022/firestitch-report.mjs +1 -1
  39. package/package.json +2 -2
  40. package/fesm2022/firestitch-report-firestitch-report-CHPA7sla.mjs.map +0 -1
@@ -29,9 +29,9 @@ import * as i3$1 from '@firestitch/zoom-pan';
29
29
  import { FsZoomPanComponent, FsZoomPanModule } from '@firestitch/zoom-pan';
30
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
31
  import { FsApi, RequestMethod, StreamEventType, DisplayApiError } from '@firestitch/api';
32
+ import { GridFieldType, GridGranularity, canonicalGridLayout, convertDateFilter, GridFormat, GridUnits, FsGridComponent, FsGridCellDirective, GridRowKind, readGridTable } from '@firestitch/grid';
32
33
  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
34
  import { toZonedTime } from 'date-fns-tz';
34
- import { canonicalGridLayout, GridFormat, GridUnits, GridFieldType, FsGridComponent } from '@firestitch/grid';
35
35
  import { MatProgressSpinner } from '@angular/material/progress-spinner';
36
36
  import { MatCheckbox } from '@angular/material/checkbox';
37
37
  import * as i3 from '@firestitch/dialog';
@@ -392,8 +392,8 @@ class ReportData {
392
392
  // resolves to a presigned URL for a zip of one CSV per component, foldered by
393
393
  // page, plus an 'Export Notes.txt'. `filters` carries the resolved values for
394
394
  // every component together; each one picks out its own. `frequency` is the
395
- // report's group-by grain (the server re-buckets only time-series charts to
396
- // it), and `filterLines` is the filter bar as the viewer reads it
395
+ // report's group-by grain (the server re-buckets only the tiles that follow
396
+ // it: time-series charts and tables over a time axis), and `filterLines` is the filter bar as the viewer reads it
397
397
  // (exportLines), which the notes file writes. Their text goes as
398
398
  // filterSummary too, for a server that does not read filterLines; the
399
399
  // lines are left out when there are none. `states` is each tile's own
@@ -4460,6 +4460,19 @@ function crumbLabel(step, locale, full) {
4460
4460
  return formatPeriodLabel(String(step.value), step.level, false, locale, full ? ReportChartDateLabel.Long : ReportChartDateLabel.Short);
4461
4461
  }
4462
4462
 
4463
+ // Whether a component is a chart on a time x-axis.
4464
+ function isTimeSeriesChart(component) {
4465
+ return component.type === 'chart' && component.config?.xAxis?.kind === 'time';
4466
+ }
4467
+ // Whether the report's group-by grain applies to a tile: the server names the
4468
+ // field it re-buckets (followsGroupBy) on a time-series chart and on a table
4469
+ // over a time axis, and drops the grain for every other tile. A server that
4470
+ // predates tables following the group-by names none, and there a chart on a
4471
+ // time x-axis is the only tile that follows it.
4472
+ function followsGroupBy(component) {
4473
+ return !!component.followsGroupBy || isTimeSeriesChart(component);
4474
+ }
4475
+
4463
4476
  // A date-range boundary as the picked LOCAL calendar day (no time, no tz).
4464
4477
  // Date-range filters are calendar-day semantics, so the boundary must carry
4465
4478
  // only Y-M-D — never a timezone-bearing instant the backend would shift.
@@ -4796,11 +4809,12 @@ function listSearchItem(value) {
4796
4809
  default: value ?? undefined,
4797
4810
  };
4798
4811
  }
4799
- // Whether the report has a chart the group-by control can re-bucket. A time
4800
- // x-axis is the whole qualification: the control changes the grain of that
4801
- // axis, and a category axis has no grain to change.
4802
- function hasTimeSeriesChart(report) {
4803
- return (report?.pages ?? []).some((page) => (page.components ?? []).some((component) => component.type === 'chart' && component.config?.xAxis?.kind === 'time'));
4812
+ // Whether the report has a tile the group-by control can re-bucket: a chart
4813
+ // on a time x-axis or a table over a time axis (followsGroupBy). A time axis
4814
+ // is the whole qualification: the control changes its grain, and a category
4815
+ // axis has no grain to change.
4816
+ function hasGroupByTile(report) {
4817
+ return (report?.pages ?? []).some((page) => (page.components ?? []).some(followsGroupBy));
4804
4818
  }
4805
4819
  // The grains the report's group-by control offers, named for the viewer. WHICH
4806
4820
  // of them comes from the report; how they read comes from GRANULARITY_OPTIONS.
@@ -4831,7 +4845,7 @@ function groupByFilterItem(report, initial) {
4831
4845
  // Only an EXPLICIT false hides the control. An absent block means a report
4832
4846
  // served by a build that predates the control being stored, and every one of
4833
4847
  // those had one — the backend resolves it the same way (ReportGroupBy).
4834
- if (groupBy?.enabled === false || !hasTimeSeriesChart(report)) {
4848
+ if (groupBy?.enabled === false || !hasGroupByTile(report)) {
4835
4849
  return null;
4836
4850
  }
4837
4851
  const values = groupByOptions(report);
@@ -5141,6 +5155,10 @@ const MATCH_ANY_LINE = 'Filters in the report bar: match any';
5141
5155
  const MATCH_ALL_CHIP = 'Match: all filters';
5142
5156
  // A group's label when it has none.
5143
5157
  const UNLABELED_GROUP = 'Filter';
5158
+ // How many picks a column filter's line names before 'and N more'.
5159
+ const GRID_FILTER_NAMED = 10;
5160
+ // What a blank key reads as in a column filter's line.
5161
+ const GRID_BLANK_KEY = '(blank)';
5144
5162
  // The label of the group-by line when the report names no control.
5145
5163
  const GROUP_BY_LABEL = 'Group By';
5146
5164
  // One filter group's value as text ('Region: West, East'), or null when the
@@ -5359,6 +5377,68 @@ function pageName(report, pageId) {
5359
5377
  }
5360
5378
  return report.pages[index].name || `Page ${index + 1}`;
5361
5379
  }
5380
+ // One column filter of a table as a file prints it: 'Region: East, West',
5381
+ // 'Exclude Region: North (rows with no Region are kept)', 'Customer: contains
5382
+ // "smith"', 'Amount: 10 to 50 (both included)', 'Ordered: Jan 2026 to Mar
5383
+ // 2026'. A key reads as its option's label, a blank key as (blank), known
5384
+ // from the null key itself; past GRID_FILTER_NAMED picks the rest are
5385
+ // counted. An exclude says what happens to the rows with no value: kept,
5386
+ // unless a blank key is among the keys left out. Null for a filter that
5387
+ // holds nothing.
5388
+ function gridFilterLineText(label, filter, field, locale) {
5389
+ const exclude = !!filter.exclude;
5390
+ const blanks = (excluded) => (exclude ? ` (rows with no ${label} ${excluded ? 'are left out' : 'are kept'})` : '');
5391
+ const line = (text, excluded = false) => `${exclude ? 'Exclude ' : ''}${label}: ${text}${blanks(excluded)}`;
5392
+ if (filter.keys?.length) {
5393
+ const names = filter.keys.slice(0, GRID_FILTER_NAMED).map((key) => gridKeyName(key, field));
5394
+ const more = filter.keys.length - names.length;
5395
+ return line(`${names.join(', ')}${more > 0 ? ` and ${more} more` : ''}`, filter.keys.some((key) => key === null));
5396
+ }
5397
+ const contains = (filter.contains ?? '').trim();
5398
+ if (contains) {
5399
+ return `${label}: ${exclude ? 'doesn\'t contain' : 'contains'} "${contains}"${blanks(false)}`;
5400
+ }
5401
+ const isDate = field?.type === GridFieldType.Date;
5402
+ const write = (end) => (isDate
5403
+ ? gridDateText(String(end), filter.granularity ?? field?.granularities?.[0], locale)
5404
+ : formatPlainNumber(Number(end), locale));
5405
+ const has = (end) => end !== undefined && end !== null && end !== '';
5406
+ if (has(filter.from) && has(filter.to)) {
5407
+ return line(`${write(filter.from)} to ${write(filter.to)}${exclude || isDate ? '' : ' (both included)'}`);
5408
+ }
5409
+ if (has(filter.from)) {
5410
+ return line(`${write(filter.from)} ${isDate ? 'or later' : 'or more'}`);
5411
+ }
5412
+ return has(filter.to) ? line(`${write(filter.to)} ${isDate ? 'or earlier' : 'or less'}`) : null;
5413
+ }
5414
+ // A key as a column filter's line names it: its option's label, else itself.
5415
+ function gridKeyName(key, field) {
5416
+ if (key === null) {
5417
+ return GRID_BLANK_KEY;
5418
+ }
5419
+ return field?.options?.find((option) => option.key !== null && String(option.key) === String(key))?.label ?? String(key);
5420
+ }
5421
+ // A date end at the grain the filter reads: 'Jan 2026', 'Q1 2026', '2026',
5422
+ // else the day.
5423
+ function gridDateText(value, granularity, locale) {
5424
+ const match = /^(\d{4})-(\d{2})/.exec(value);
5425
+ if (!match) {
5426
+ return value;
5427
+ }
5428
+ const year = Number(match[1]);
5429
+ const month = Number(match[2]);
5430
+ switch (granularity) {
5431
+ case GridGranularity.Year:
5432
+ return String(year);
5433
+ case GridGranularity.Quarter:
5434
+ return `Q${Math.ceil(month / 3)} ${year}`;
5435
+ case GridGranularity.Month:
5436
+ return new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric', timeZone: 'UTC' })
5437
+ .format(new Date(Date.UTC(year, month - 1, 1)));
5438
+ default:
5439
+ return dateBoundLabel(value, locale) ?? value;
5440
+ }
5441
+ }
5362
5442
 
5363
5443
  // Filters inside text blocks: what each {{filter.<id>}} token reads as. The
5364
5444
  // value is the viewer's current one for the group, the one the filter bar
@@ -5536,18 +5616,10 @@ const PAGE_DIMENSION_MAX = 50;
5536
5616
  // ReportHeaderFooter::MAX_HEIGHT on the server, which clamps.
5537
5617
  const HEADER_FOOTER_MAX = 3;
5538
5618
 
5539
- // Whether a component is a chart on a time x-axis: the only component the
5540
- // report's group-by grain applies to. A deliberate mirror of the server's
5541
- // ReportQueryRunner::_isTimeSeriesChart, which is canonical and drops the
5542
- // grain for every other component; kept so a product pinned to an older
5543
- // framework does not re-bucket a non-time chart.
5544
- function isTimeSeriesChart(component) {
5545
- return component.type === 'chart' && component.config?.xAxis?.kind === 'time';
5546
- }
5547
5619
  // The interaction state one tile's data request carries, read off a snapshot
5548
- // of the screen (the state service's snapshot()): the group-by grain
5549
- // for a time-series chart, a list's sort, search and hidden columns, a
5550
- // chart's drill path, and, while the groups match 'any', the groups that
5620
+ // of the screen (the state service's snapshot()): the group-by grain for a
5621
+ // tile that follows it (followsGroupBy), a list's sort, search and hidden
5622
+ // columns, a chart's drill path, and, while the groups match 'any', the groups that
5551
5623
  // combine (matchAnyGroupIds). The tile on screen, its CSV, the PDF and
5552
5624
  // PowerPoint collector and the zip all build their state here, so a file
5553
5625
  // always holds what the screen shows. A list's paging is the caller's. Never
@@ -5561,7 +5633,7 @@ function tileDataState(report, component, snapshot) {
5561
5633
  return state;
5562
5634
  }
5563
5635
  const tile = snapshot.tiles?.[component.id];
5564
- if (isTimeSeriesChart(component) && isChartGranularity(snapshot.groupBy)) {
5636
+ if (followsGroupBy(component) && isChartGranularity(snapshot.groupBy)) {
5565
5637
  state.frequency = snapshot.groupBy;
5566
5638
  }
5567
5639
  if (component.type === 'list') {
@@ -5586,15 +5658,17 @@ function tileDataState(report, component, snapshot) {
5586
5658
  }
5587
5659
  // Each tile's state for the zip export (tileDataState), by component id: the
5588
5660
  // tiles of every page, or of `pageId` alone. The zip holds no header or
5589
- // footer tile. A tile with nothing to send is left out, so an untouched report
5590
- // sends no states at all.
5591
- function zipExportStates(report, snapshot, pageId = null) {
5661
+ // footer tile. A table adds the layout the viewer put it in (`gridLayout`,
5662
+ // null while it shows its stored one). A tile with nothing to send is left
5663
+ // out, so an untouched report sends no states at all.
5664
+ function zipExportStates(report, snapshot, pageId = null, gridLayout = () => null) {
5592
5665
  const states = {};
5593
5666
  (report.pages ?? [])
5594
5667
  .filter((page) => !pageId || page.id === pageId)
5595
5668
  .flatMap((page) => page.components ?? [])
5596
5669
  .forEach((component) => {
5597
- const state = tileDataState(report, component, snapshot);
5670
+ const layout = component.type === 'grid' ? gridLayout(component) : null;
5671
+ const state = { ...tileDataState(report, component, snapshot), ...(layout ? { layout } : {}) };
5598
5672
  if (Object.keys(state).length) {
5599
5673
  states[component.id] = state;
5600
5674
  }
@@ -5966,9 +6040,17 @@ function listPriorAlias(outputs, alias, output) {
5966
6040
  : null;
5967
6041
  }
5968
6042
 
5969
- // A table tile's layouts: the one its config authors, and the canonical key
5970
- // two layouts are compared by.
6043
+ // A table tile's layouts: the one its config authors, the canonical key two
6044
+ // layouts are compared by, and a viewer's layout moved onto a new authored one.
5971
6045
  const NO_LAYOUT = {};
6046
+ // The report's grains by the grid's name for each.
6047
+ const GRID_GRANULARITIES = {
6048
+ day: GridGranularity.Day,
6049
+ week: GridGranularity.Week,
6050
+ month: GridGranularity.Month,
6051
+ quarter: GridGranularity.Quarter,
6052
+ year: GridGranularity.Year,
6053
+ };
5972
6054
  // The layout a table tile's config authors, or none.
5973
6055
  function authoredGridLayout(component) {
5974
6056
  return component.config?.layout ?? NO_LAYOUT;
@@ -5978,6 +6060,51 @@ function authoredGridLayout(component) {
5978
6060
  function gridLayoutKey(layout) {
5979
6061
  return JSON.stringify(canonicalGridLayout(layout));
5980
6062
  }
6063
+ // A viewer's layout moved onto the layout authored now, `base` being the key
6064
+ // of the authored layout they started from: the new authored layout, with the
6065
+ // viewer's own filters and sort (what `base` did not have) kept on the fields
6066
+ // it still places. A field the viewer filters takes their filters in place of
6067
+ // the authored ones, and a sort they changed replaces the authored sort.
6068
+ function rebasedGridLayout(session, base, authored) {
6069
+ const started = JSON.parse(base);
6070
+ const viewer = canonicalGridLayout(session);
6071
+ const placed = new Set([
6072
+ ...(authored.rows ?? []),
6073
+ ...(authored.columns ?? []),
6074
+ ...(authored.values ?? []),
6075
+ ...(authored.details ?? []),
6076
+ ].map((placement) => placement.field));
6077
+ const startedFilters = new Set((started.filters ?? []).map((filter) => JSON.stringify(filter)));
6078
+ const filters = (viewer.filters ?? [])
6079
+ .filter((filter) => placed.has(filter.field) && !startedFilters.has(JSON.stringify(filter)));
6080
+ const filtered = new Set(filters.map((filter) => filter.field));
6081
+ const sorted = JSON.stringify(viewer.sort ?? []) !== JSON.stringify(started.sort ?? []);
6082
+ const sort = (viewer.sort ?? []).filter((term) => placed.has(term.field));
6083
+ return canonicalGridLayout({
6084
+ ...authored,
6085
+ filters: [...(authored.filters ?? []).filter((filter) => !filtered.has(filter.field)), ...filters],
6086
+ sort: sorted && sort.length ? sort : authored.sort,
6087
+ });
6088
+ }
6089
+ // A table's layout once the grain `field` reads at moves to `granularity` (the
6090
+ // report's group-by changed): its column filter on `field` converts to the
6091
+ // same days at the new grain when its edges line up, and is taken out
6092
+ // otherwise (convertDateFilter), so the read never sends a filter the field no
6093
+ // longer offers. `cleared` names the filter taken out and the grain the table
6094
+ // now groups by, as fs-grid's status line reads it. A layout with no filter on
6095
+ // the field, or one already at the grain, comes back as it is.
6096
+ function grainedGridLayout(layout, field, granularity) {
6097
+ const grain = GRID_GRANULARITIES[granularity];
6098
+ const filter = (layout.filters ?? []).find((item) => item.field === field);
6099
+ if (!filter || (filter.granularity ?? GridGranularity.Day) === grain) {
6100
+ return { layout, cleared: null };
6101
+ }
6102
+ const converted = convertDateFilter(filter, grain);
6103
+ const filters = (layout.filters ?? [])
6104
+ .map((item) => (item === filter ? converted : item))
6105
+ .filter((item) => !!item);
6106
+ return { layout: { ...layout, filters }, cleared: converted ? null : { field, granularity: grain } };
6107
+ }
5981
6108
 
5982
6109
  // Whether two stored filter-group defaults are the same. Compared on the fields a
5983
6110
  // default can actually carry, so key order in the payload can't read as a change.
@@ -6140,9 +6267,11 @@ class ReportFilterStateService {
6140
6267
  // Whether the screen differs from the report's authored defaults
6141
6268
  // (stateDiffers): what the report bar's 'Clear filters' shows for. A switch
6142
6269
  // of page does not count, and the temporary state never does.
6270
+ // A table tile in a layout of the viewer's own counts too; it stays out of
6271
+ // the snapshot, so no saved filter or link carries it.
6143
6272
  differsFromDefaults = computed(() => {
6144
6273
  this._revision();
6145
- return stateDiffers(this.authoredConfig(), this.snapshot());
6274
+ return this._gridLayouts().size > 0 || stateDiffers(this.authoredConfig(), this.snapshot());
6146
6275
  });
6147
6276
  _values = new Map();
6148
6277
  _groups = new Map();
@@ -6152,7 +6281,8 @@ class ReportFilterStateService {
6152
6281
  // its default straight back. A change to the stored default releases it.
6153
6282
  _cleared = new Set();
6154
6283
  // The report's group-by control: a single report-wide bucket override for all
6155
- // time-series charts. Unset = each chart uses its authored granularity. Never
6284
+ // the tiles that follow it (followsGroupBy). Unset = each one uses its
6285
+ // authored granularity. Never
6156
6286
  // saved to the report, and carried in the data request's
6157
6287
  // `state`, so it propagates through to the PDF/PowerPoint exports too. Named
6158
6288
  // for the `frequency` wire key it travels under, not for the control's label.
@@ -6212,6 +6342,16 @@ class ReportFilterStateService {
6212
6342
  // changed from (ReportGridSessionLayout.base). A table showing its authored
6213
6343
  // layout has no entry, and only a table has one.
6214
6344
  _gridLayouts = signal(new Map());
6345
+ // The groups a viewer left open in each table tile, by component id, so a
6346
+ // tile drawn again (a page switch) opens them. Session only: no Clear
6347
+ // resets them, differsFromDefaults never reads them, and fs-grid drops
6348
+ // them itself once the row levels change.
6349
+ _gridExpansions = signal(new Map());
6350
+ // By table tile, the column filter a group-by change took out of it and
6351
+ // the grain the table now groups by (_grainGridLayouts()), for fs-grid's
6352
+ // status line. fs-grid hides it once the field is filtered again; a Clear
6353
+ // drops it.
6354
+ _gridClearedFilters = signal(new Map());
6215
6355
  // The temporary layer: kept apart from everything above, so snapshot()
6216
6356
  // and differsFromDefaults never read it.
6217
6357
  _temporary = signal(emptyTemporaryState());
@@ -6278,14 +6418,18 @@ class ReportFilterStateService {
6278
6418
  frequency() {
6279
6419
  return this._frequency;
6280
6420
  }
6421
+ // Each table that follows the group-by has its filter on the group-by's
6422
+ // field moved to the new grain first (_grainGridLayouts()), so a table on
6423
+ // screen reads once, with both.
6281
6424
  setFrequency(frequency) {
6282
6425
  this._frequency = frequency;
6426
+ this._grainGridLayouts();
6283
6427
  this._frequencyChanged$.next();
6284
6428
  this._stateChanged$.next();
6285
6429
  }
6286
- // Fires whenever the group-by grain changes — every time-series chart listens
6287
- // so it refetches and re-buckets (the override isn't scoped to any filter
6288
- // group, so changesFor() can't carry it).
6430
+ // Fires whenever the group-by grain changes — every tile that follows it
6431
+ // listens so it refetches and re-buckets (the override isn't scoped to any
6432
+ // filter group, so changesFor() can't carry it).
6289
6433
  frequencyChanges() {
6290
6434
  return this._frequencyChanged$.asObservable();
6291
6435
  }
@@ -6538,24 +6682,27 @@ class ReportFilterStateService {
6538
6682
  // the tile reads as stored again. fs-grid reads the change itself, so only
6539
6683
  // changes() hears about it, and only on a real change.
6540
6684
  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);
6685
+ if (this._putGridLayout(component, layout)) {
6686
+ this._stateChanged$.next();
6553
6687
  }
6554
- else {
6555
- next.set(component.id, { layout, base });
6688
+ }
6689
+ // The column filter a group-by change took out of a table tile, or null
6690
+ // (_gridClearedFilters).
6691
+ gridClearedFilter(component) {
6692
+ return this._gridClearedFilters().get(component.id) ?? null;
6693
+ }
6694
+ // The groups a viewer left open in a table tile, or undefined: the tile
6695
+ // opens as its layout says.
6696
+ gridExpansion(component) {
6697
+ return this._gridExpansions().get(component.id);
6698
+ }
6699
+ // Records the groups open in a table tile, as fs-grid writes them out. It
6700
+ // changes no read, so nothing hears about it.
6701
+ setGridExpansion(component, expansion) {
6702
+ if (component.type !== 'grid' || !expansion || this._gridExpansions().get(component.id) === expansion) {
6703
+ return;
6556
6704
  }
6557
- this._gridLayouts.set(next);
6558
- this._stateChanged$.next();
6705
+ this._gridExpansions.set(new Map(this._gridExpansions()).set(component.id, expansion));
6559
6706
  }
6560
6707
  // The durable state on screen as one document: the page, every group the
6561
6708
  // report has (null when it holds nothing, a cleared default included), the
@@ -6921,6 +7068,56 @@ class ReportFilterStateService {
6921
7068
  this.listHidden.set(new Map());
6922
7069
  this._drills.set(new Map());
6923
7070
  this._gridLayouts.set(new Map());
7071
+ this._gridClearedFilters.set(new Map());
7072
+ }
7073
+ // setGridLayout() without the change event; true when the layout changed.
7074
+ _putGridLayout(component, layout) {
7075
+ const base = gridLayoutKey(authoredGridLayout(component));
7076
+ const key = gridLayoutKey(layout);
7077
+ const current = this._gridLayouts().get(component.id);
7078
+ const unchanged = key === base
7079
+ ? !current
7080
+ : current?.base === base && gridLayoutKey(current.layout) === key;
7081
+ if (component.type !== 'grid' || unchanged) {
7082
+ return false;
7083
+ }
7084
+ const next = new Map(this._gridLayouts());
7085
+ if (key === base) {
7086
+ next.delete(component.id);
7087
+ }
7088
+ else {
7089
+ next.set(component.id, { layout, base });
7090
+ }
7091
+ this._gridLayouts.set(next);
7092
+ return true;
7093
+ }
7094
+ // Every table that follows the group-by (followsGroupBy), its column filter
7095
+ // on the group-by's field moved to the grain it now reads at: the viewer's
7096
+ // pick, else the table's own (grainedGridLayout()). Tables on other pages
7097
+ // move too, so their files, the chat and their next paint read the moved
7098
+ // filter. A filter taken out is noted on its tile (gridClearedFilter()); a
7099
+ // note already there names the new grain, unless the field is filtered
7100
+ // again, which ends it.
7101
+ _grainGridLayouts() {
7102
+ const cleared = new Map(this._gridClearedFilters());
7103
+ this._tiles().forEach((component) => {
7104
+ const groupBy = component.type === 'grid' ? component.followsGroupBy : null;
7105
+ const granularity = this._frequency ?? groupBy?.granularity;
7106
+ if (!groupBy || !granularity) {
7107
+ return;
7108
+ }
7109
+ const grained = grainedGridLayout(this.gridLayout(component), groupBy.field, granularity);
7110
+ const filtered = (grained.layout.filters ?? []).some((filter) => filter.field === groupBy.field);
7111
+ const note = grained.cleared ?? (filtered ? null : cleared.get(component.id));
7112
+ if (note) {
7113
+ cleared.set(component.id, { ...note, granularity: GRID_GRANULARITIES[granularity] });
7114
+ }
7115
+ else {
7116
+ cleared.delete(component.id);
7117
+ }
7118
+ this._putGridLayout(component, grained.layout);
7119
+ });
7120
+ this._gridClearedFilters.set(cleared);
6924
7121
  }
6925
7122
  // One chart's drill path; an empty path is the chart's top.
6926
7123
  _setDrillPath(componentId, path) {
@@ -6933,8 +7130,8 @@ class ReportFilterStateService {
6933
7130
  }
6934
7131
  this._drills.set(next);
6935
7132
  }
6936
- // After resetToDefaults(): every bar and tile repaints, and the
6937
- // time-series charts refetch when the grain moved.
7133
+ // After resetToDefaults(): every bar and tile repaints, and the tiles that
7134
+ // follow the group-by refetch when the grain moved.
6938
7135
  _notifyReset(frequency) {
6939
7136
  this._stateVersion.update((version) => version + 1);
6940
7137
  this._changed$.next(null);
@@ -6954,39 +7151,55 @@ class ReportFilterStateService {
6954
7151
  return this._tiles()
6955
7152
  .some((component) => component.id === componentId && component.type === 'chart');
6956
7153
  }
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.
7154
+ // Drops each table layout and expansion whose tile is gone or is no table
7155
+ // any more. A layout whose tile is now authored otherwise (an edit, the
7156
+ // agent's) moves onto the new authored layout, keeping the viewer's filters
7157
+ // and sort terms on fields it still places (rebasedGridLayout), so the tile
7158
+ // shows both changes; one that then draws the authored layout is dropped.
6959
7159
  _pruneGridLayouts() {
6960
7160
  const grids = new Map(this._tiles()
6961
7161
  .filter((component) => component.type === 'grid')
6962
7162
  .map((component) => [component.id, component]));
6963
- const kept = new Map([...this._gridLayouts()]
6964
- .filter(([componentId, session]) => {
7163
+ const kept = new Map();
7164
+ this._gridLayouts().forEach((session, componentId) => {
6965
7165
  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
- }
7166
+ if (!component) {
7167
+ return;
7168
+ }
7169
+ const authored = authoredGridLayout(component);
7170
+ const base = gridLayoutKey(authored);
7171
+ const layout = session.base === base ? session.layout : rebasedGridLayout(session.layout, session.base, authored);
7172
+ if (gridLayoutKey(layout) !== base) {
7173
+ kept.set(componentId, { layout, base });
7174
+ }
7175
+ });
7176
+ this._gridLayouts.set(kept);
7177
+ this._gridExpansions.set(new Map([...this._gridExpansions()]
7178
+ .filter(([componentId]) => grids.has(componentId))));
7179
+ this._gridClearedFilters.set(new Map([...this._gridClearedFilters()]
7180
+ .filter(([componentId]) => grids.has(componentId))));
6971
7181
  }
6972
7182
  // Keeps the page, drill and table state to what the report (re)loaded by
6973
7183
  // 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.
7184
+ // has it, and each table's layout and open groups while it is still a table
7185
+ // (_pruneGridLayouts). On another report it starts again: match 'all', no
7186
+ // drill, every table as authored and closed, and nothing temporary.
6978
7187
  _prunePageState(report) {
6979
7188
  if (this._report()?.id !== report.id) {
6980
7189
  this._match.set('all');
6981
7190
  this._pageId.set(null);
6982
7191
  this._drills.set(new Map());
6983
7192
  this._gridLayouts.set(new Map());
7193
+ this._gridExpansions.set(new Map());
7194
+ this._gridClearedFilters.set(new Map());
6984
7195
  this._temporary.set(emptyTemporaryState());
6985
7196
  }
6986
7197
  this._report.set(report);
6987
7198
  this._drills.set(new Map([...this._drills()]
6988
7199
  .filter(([componentId]) => this._isChart(componentId))));
6989
7200
  this._pruneGridLayouts();
7201
+ // A table the report now authors otherwise reads at the grain on screen.
7202
+ this._grainGridLayouts();
6990
7203
  if (this._pageId() && !(report.pages ?? []).some((page) => page.id === this._pageId())) {
6991
7204
  this._pageId.set(null);
6992
7205
  }
@@ -8255,6 +8468,44 @@ function readError(error) {
8255
8468
  return error instanceof Error ? error : new Error(READ_FAILED);
8256
8469
  }
8257
8470
 
8471
+ // A detail cell of an output field (a prior, a change, a % change, a rank)
8472
+ // as a list writes it: a change reads its prior beside it on the line, so a
8473
+ // missing baseline reads '—' and a zero one 'n/a', and the cell's title says
8474
+ // why, as the list's hover does. A value it holds keeps the grid's own text
8475
+ // (its placement's format); a rank change is written whole, since a new rank
8476
+ // reads apart from an unranked line. Every other cell is the grid's text,
8477
+ // with no title.
8478
+ class ReportGridCellPipe {
8479
+ // The cell's raw value, its field, its line's record (absent on a group or
8480
+ // total row) and the grid's own text, from the fsGridCell context.
8481
+ transform(value, field, record, text, report, format) {
8482
+ const name = field?.name ?? '';
8483
+ const output = record ? report?.outputs?.[name] : null;
8484
+ if (!output || !record) {
8485
+ return { text, title: null };
8486
+ }
8487
+ const priorAlias = output.role === 'change' || output.role === 'percent_change'
8488
+ ? laneOutputAlias(report?.outputs, name, 'prior')
8489
+ : null;
8490
+ const formatted = formatOutputValue(value, output, null, format, {
8491
+ ...(priorAlias ? { prior: record[priorAlias] } : {}),
8492
+ floor: report?.comparison?.floor ?? null,
8493
+ empty: '',
8494
+ });
8495
+ const isMissing = value === null || value === undefined || value === '';
8496
+ return { text: isMissing || output.kind === 'rank' ? formatted.text : text, title: formatted.title };
8497
+ }
8498
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportGridCellPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
8499
+ static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "18.2.14", ngImport: i0, type: ReportGridCellPipe, isStandalone: true, name: "fsReportGridCell" });
8500
+ }
8501
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportGridCellPipe, decorators: [{
8502
+ type: Pipe,
8503
+ args: [{
8504
+ name: 'fsReportGridCell',
8505
+ standalone: true,
8506
+ }]
8507
+ }] });
8508
+
8258
8509
  const VALUE_FORMATS = {
8259
8510
  [GridFormat.Number]: ReportValueFormat.Number,
8260
8511
  [GridFormat.Percent]: ReportValueFormat.Percent,
@@ -8354,12 +8605,20 @@ function decimalMark(locale) {
8354
8605
  // under an older state version is dropped.
8355
8606
  // - The layout on screen is the state service's (gridLayout): the viewer's
8356
8607
  // 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.
8608
+ // authored one. It outlives a page switch, and the chat reads it. So do
8609
+ // the groups the viewer left open (gridExpansion), which a Clear leaves.
8358
8610
  // - A state version move (Clear filters, a saved filter) skips the wait; the
8359
8611
  // reset has put the authored layout back in the same tick, so it costs one
8360
8612
  // read.
8613
+ // - A table that follows the report's group-by (followsGroupBy) sends its
8614
+ // grain, and reads again at once when it changes. The state service has
8615
+ // moved its filter on the group-by's field to the new grain by then, or
8616
+ // taken it out (clearedFilter, which the status line names), so one read
8617
+ // goes out and nothing paints unfiltered.
8361
8618
  // - fs-grid compares the layout and params by value: a cloned value never
8362
8619
  // reads again.
8620
+ // - An output's detail cell is drawn by fsReportGridCell, so a change tells
8621
+ // a missing prior from a zero one and says why on hover, as a list does.
8363
8622
  class ComponentGridComponent {
8364
8623
  reportId = input.required();
8365
8624
  component = input.required();
@@ -8369,7 +8628,11 @@ class ComponentGridComponent {
8369
8628
  locale: () => this._filterState.formatContext().locale,
8370
8629
  }));
8371
8630
  layout = computed(() => this._filterState.gridLayout(this.component()));
8631
+ clearedFilter = computed(() => this._filterState.gridClearedFilter(this.component()));
8632
+ // Read once: fs-grid owns the open groups from then on and writes them out.
8633
+ expansion = signal(undefined);
8372
8634
  noteData = computed(() => this.source().report());
8635
+ formatContext = computed(() => this._filterState.formatContext());
8373
8636
  formatter = computed(() => reportGridFormatter({
8374
8637
  format: this._filterState.formatContext(),
8375
8638
  timezone: this._filterState.timezone(),
@@ -8382,17 +8645,26 @@ class ComponentGridComponent {
8382
8645
  // The state version the params on screen were read under.
8383
8646
  _paramsVersion = 0;
8384
8647
  ngOnInit() {
8648
+ this.expansion.set(this._filterState.gridExpansion(this.component()));
8385
8649
  this._init();
8386
8650
  }
8387
8651
  setLayout(layout) {
8388
8652
  this._filterState.setGridLayout(this.component(), layout);
8389
8653
  }
8654
+ setExpansion(expansion) {
8655
+ this._filterState.setGridExpansion(this.component(), expansion);
8656
+ }
8390
8657
  _init() {
8391
8658
  this._paramsVersion = this._filterState.stateVersion();
8392
8659
  this.params.set(this._readParams());
8393
8660
  this._filterState.changesFor(this.component())
8394
8661
  .pipe(switchMap$1(() => this._settled$()), tap$1(() => this._applyParams()), takeUntilDestroyed(this._destroyRef))
8395
8662
  .subscribe();
8663
+ if (followsGroupBy(this.component())) {
8664
+ this._filterState.frequencyChanges()
8665
+ .pipe(takeUntilDestroyed(this._destroyRef))
8666
+ .subscribe(() => this._applyParams());
8667
+ }
8396
8668
  }
8397
8669
  // A change, once it has settled: at once when the state version moved,
8398
8670
  // else after the debounce; dropped when the version moves while it waits,
@@ -8407,24 +8679,28 @@ class ComponentGridComponent {
8407
8679
  this._paramsVersion = this._filterState.stateVersion();
8408
8680
  this.params.set(this._readParams());
8409
8681
  }
8410
- // The tile's report filters and 'any' groups as they stand.
8682
+ // The tile's report filters, 'any' groups and group-by grain as they stand
8683
+ // (tileState()).
8411
8684
  _readParams() {
8412
8685
  const component = this.component();
8413
- const matchAnyGroupIds = this._filterState.tileState(component).matchAnyGroupIds;
8686
+ const { matchAnyGroupIds, frequency } = this._filterState.tileState(component);
8414
8687
  return {
8415
8688
  filters: this._filterState.resolveForComponent(component),
8416
8689
  ...(matchAnyGroupIds?.length ? { matchAnyGroupIds } : {}),
8690
+ ...(frequency ? { frequency } : {}),
8417
8691
  };
8418
8692
  }
8419
8693
  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 });
8694
+ 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 [expansion]=\"expansion()\"\n [clearedFilter]=\"clearedFilter()\"\n (expansionChange)=\"setExpansion($event)\"\n [params]=\"params\"\n [controls]=\"controls()\"\n [formatter]=\"formatter()\"\n (layoutChange)=\"setLayout($event)\">\n <ng-template fsGridCell let-value let-field=\"field\" let-record=\"record\" let-text=\"text\">\n @if (value | fsReportGridCell: field : record : text : noteData() : formatContext(); as shown) {\n <span [attr.title]=\"shown.title\">{{ shown.text }}</span>\n }\n </ng-template>\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: "directive", type: FsGridCellDirective, selector: "[fsGridCell]" }, { kind: "component", type: ComponentNoteComponent, selector: "app-report-component-note", inputs: ["data", "windows", "windowDescriptions", "windowNotes"] }, { kind: "pipe", type: ReportGridCellPipe, name: "fsReportGridCell" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
8421
8695
  }
8422
8696
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentGridComponent, decorators: [{
8423
8697
  type: Component,
8424
8698
  args: [{ selector: 'app-report-component-grid', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
8425
8699
  FsGridComponent,
8700
+ FsGridCellDirective,
8426
8701
  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"] }]
8702
+ ReportGridCellPipe,
8703
+ ], 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 [expansion]=\"expansion()\"\n [clearedFilter]=\"clearedFilter()\"\n (expansionChange)=\"setExpansion($event)\"\n [params]=\"params\"\n [controls]=\"controls()\"\n [formatter]=\"formatter()\"\n (layoutChange)=\"setLayout($event)\">\n <ng-template fsGridCell let-value let-field=\"field\" let-record=\"record\" let-text=\"text\">\n @if (value | fsReportGridCell: field : record : text : noteData() : formatContext(); as shown) {\n <span [attr.title]=\"shown.title\">{{ shown.text }}</span>\n }\n </ng-template>\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
8704
  }] });
8429
8705
 
8430
8706
  // A KPI's trend over its last periods: a line that stretches to the card's
@@ -10135,9 +10411,9 @@ class ReportComponentComponent {
10135
10411
  this._filterState.changesFor(this.component)
10136
10412
  .pipe(takeUntilDestroyed(this._destroyRef))
10137
10413
  .subscribe(() => this._refresh$.next());
10138
- // A group-by change re-buckets every time-series chart; it isn't scoped
10139
- // to a filter group, so it rides its own stream.
10140
- if (this._isTimeSeries) {
10414
+ // A group-by change re-buckets every tile that follows it; it isn't
10415
+ // scoped to a filter group, so it rides its own stream.
10416
+ if (followsGroupBy(this.component)) {
10141
10417
  this._filterState.frequencyChanges()
10142
10418
  .pipe(takeUntilDestroyed(this._destroyRef))
10143
10419
  .subscribe(() => {
@@ -10327,17 +10603,18 @@ class ReportComponentComponent {
10327
10603
  }
10328
10604
  }
10329
10605
  // The CSV is what the screen shows, so it carries the tile's interaction
10330
- // state (tileDataState): the group-by grain for a time-series chart, a
10606
+ // state (tileDataState): the group-by grain for a tile that follows it, a
10331
10607
  // chart's drill path, the viewer's header sort, search and hidden columns
10332
10608
  // for a list (a searched list exports only the rows the search leaves, and
10333
10609
  // the hidden columns stay out of the file), and the groups that match
10334
- // 'any'; and its title as the header reads it (shownTitle), which the
10335
- // server names the file after. A note the server returns (the file was
10336
- // cut) is shown before the download starts, so a short file never passes
10337
- // for the whole result.
10610
+ // 'any'; a table's layout as the viewer changed it; and its title as the
10611
+ // header reads it (shownTitle), which the server names the file after. A
10612
+ // note the server returns (the file was cut) is shown before the download
10613
+ // starts, so a short file never passes for the whole result.
10338
10614
  exportCsv() {
10339
10615
  const filters = this._filterState.resolveForComponent(this.component);
10340
- const state = this._filterState.tileState(this.component);
10616
+ const layout = this.component.type === 'grid' ? this._filterState.gridSessionLayout(this.component) : null;
10617
+ const state = { ...this._filterState.tileState(this.component), ...(layout ? { layout } : {}) };
10341
10618
  const title = this.shownTitle;
10342
10619
  this._process.download(`Exporting ${title || 'component'}`, this._reportData.exportComponent(this.reportId, this.component.id, filters, state, title)
10343
10620
  .pipe(tap$1(({ notes }) => notes?.length && this._message.warning(notes.join(' '))), map$1(({ url }) => url)));
@@ -10590,11 +10867,6 @@ class ReportComponentComponent {
10590
10867
  h: this.component.h,
10591
10868
  });
10592
10869
  }
10593
- // A chart on a time x-axis — the only component the report's group-by control
10594
- // applies to (isTimeSeriesChart, the browser-side mirror of the server's rule).
10595
- get _isTimeSeries() {
10596
- return isTimeSeriesChart(this.component);
10597
- }
10598
10870
  // Auto height (box fits its content) vs fixed (use `h` exactly). Derived from
10599
10871
  // type, server-side: KPI tiles are auto; charts and lists are fixed — charts
10600
10872
  // fit the box and never overflow, lists scroll inside it.
@@ -10641,9 +10913,9 @@ class ReportComponentComponent {
10641
10913
  this.error.set(null);
10642
10914
  const filters = this._filterState.resolveForComponent(this.component);
10643
10915
  // The tile's interaction state, the one its CSV and every export carry
10644
- // (tileDataState): a time-series chart's grain (when the viewer picked
10645
- // one) so the backend re-buckets, a chart's drill path, and the groups
10646
- // that match 'any'.
10916
+ // (tileDataState): the grain of a tile that follows the group-by (when
10917
+ // the viewer picked one) so the backend re-buckets, a chart's drill
10918
+ // path, and the groups that match 'any'.
10647
10919
  const state = this._filterState.tileState(this.component);
10648
10920
  return this._reportData.componentData(this.reportId, this.component.id, filters, state);
10649
10921
  }
@@ -13063,6 +13335,52 @@ function nameInputId(groupId) {
13063
13335
  return `filterName${groupId}`;
13064
13336
  }
13065
13337
 
13338
+ // A table tile as the PDF and the deck draw it: the printed table
13339
+ // readGridTable() reads (GridTable) as heading rows, body rows and a total
13340
+ // row of plain cells, and the lines its notes add. Pure, so both renderers
13341
+ // draw one table one way.
13342
+ // The most pivot columns a file draws, from the first.
13343
+ const GRID_EXPORT_COLUMNS = 60;
13344
+ // The table's heading, body and total row as cells. A group's label carries
13345
+ // its row count after it, as the screen writes it.
13346
+ function gridExportTable(table) {
13347
+ const numeric = table.columns.map((column) => column.numeric);
13348
+ const head = Array.from({ length: Math.max(1, table.headingRows) }, (_, row) => table.headings
13349
+ .filter((heading) => heading.row === row)
13350
+ .sort((a, b) => a.column - b.column)
13351
+ .map((heading) => exportCell$1(headingText(heading), true, 0, heading.numeric, heading.columnSpan, heading.rowSpan)));
13352
+ const line = (row) => row.cells.map((cell, index) => {
13353
+ const isLabel = index === 0 && table.groupColumn;
13354
+ return exportCell$1(isLabel && row.count ? `${cell.text} (${row.count})` : cell.text, row.kind !== GridRowKind.Detail || !!table.columns[index]?.emphasis, isLabel ? row.depth : 0, numeric[index] ?? false);
13355
+ });
13356
+ return {
13357
+ head,
13358
+ body: table.rows.map(line),
13359
+ foot: table.totalRow ? line(table.totalRow) : null,
13360
+ numeric,
13361
+ moreRows: table.moreRows,
13362
+ };
13363
+ }
13364
+ // The lines a table adds under its box: the screen's notes, the pivot
13365
+ // columns it leaves out, and each column filter of the layout it was read in.
13366
+ function gridExportNotes(table, layout, locale) {
13367
+ const fields = new Map(table.fields.map((field) => [field.name, field]));
13368
+ const filters = (layout.filters ?? [])
13369
+ .map((filter) => gridFilterLineText(fields.get(filter.field)?.label ?? filter.field, filter, fields.get(filter.field), locale))
13370
+ .filter((text) => !!text);
13371
+ return [
13372
+ ...table.notes,
13373
+ ...(table.shownColumns < table.columnCount ? [`Showing the first ${table.shownColumns} of ${table.columnCount} columns`] : []),
13374
+ ...filters,
13375
+ ];
13376
+ }
13377
+ function headingText(heading) {
13378
+ return heading.hint ? `${heading.label} (${heading.hint})` : heading.label;
13379
+ }
13380
+ function exportCell$1(text, bold, indent, numeric, colSpan = 1, rowSpan = 1) {
13381
+ return { text, colSpan, rowSpan, bold, indent, numeric };
13382
+ }
13383
+
13066
13384
  // How far a KPI card's rows shrink to fit a short tile. A tile shorter still
13067
13385
  // lets the card run past its foot rather than print it unreadably small.
13068
13386
  const KPI_EXPORT_MIN_SCALE = 0.5;
@@ -13103,8 +13421,11 @@ function emptyLabel(item) {
13103
13421
  // the matching rows: 'Orders (matching "north")'. An untitled one reads
13104
13422
  // 'Matching "north"'. '' when there is nothing to print. Shared by the PDF
13105
13423
  // and the deck, continuation pages included.
13106
- function exportTitle(component, tokens, search, crumbs) {
13424
+ function exportTitle(component, tokens, search, crumbs, filtered = false) {
13107
13425
  const title = crumbedTitle(fillTitleTokens(component.title, tokens), crumbs ?? []);
13426
+ if (filtered) {
13427
+ return title ? `${title} (filtered)` : 'Filtered';
13428
+ }
13108
13429
  if (!search) {
13109
13430
  return title;
13110
13431
  }
@@ -13236,7 +13557,16 @@ function exportWindowsLine(item, timezone, locale) {
13236
13557
  // descriptions are in, since a file has no tooltip or header to carry them.
13237
13558
  // Empty when the data has none of the keys.
13238
13559
  function exportNoteLines(item, timezone, locale) {
13239
- return componentNoteLines(item.data, { locale, timezone }, { windows: false, windowDescriptions: true });
13560
+ return [
13561
+ ...componentNoteLines(item.data, { locale, timezone }, { windows: false, windowDescriptions: true }),
13562
+ ...(item.tableNotes ?? []),
13563
+ ];
13564
+ }
13565
+ // Whether a tile has nothing to draw: no rows, or a table with no line.
13566
+ function isEmptyTile(item) {
13567
+ return item.table
13568
+ ? !item.table.rows.length && !item.table.totalRow
13569
+ : !item.data?.rows?.length;
13240
13570
  }
13241
13571
  // Whether a KPI card has more to draw than its value and label. Without, both
13242
13572
  // exports draw the tile exactly as they always have.
@@ -13429,7 +13759,10 @@ class ReportExportCollectorService {
13429
13759
  filters: Object.fromEntries(tiles.map((tile) => [tile.id, this._filterState.resolveForComponent(tile)])),
13430
13760
  valueFilters: Object.fromEntries(values
13431
13761
  .map((value) => [value.id, this._filterState.resolveForComponent(value)])),
13432
- states: Object.fromEntries(tiles.map((tile) => [tile.id, tileDataState(report, tile, state)])),
13762
+ states: Object.fromEntries(tiles.map((tile) => [tile.id, this._tileState(report, tile, state)])),
13763
+ gridExpansions: Object.fromEntries(tiles
13764
+ .map((tile) => [tile.id, this._filterState.gridExpansion(tile)])
13765
+ .filter(([, expansion]) => !!expansion)),
13433
13766
  frequency: this._filterState.frequency(),
13434
13767
  features: offeredViewerFeatures(report.viewerFeatures),
13435
13768
  formatContext: this._filterState.formatContext(),
@@ -13513,10 +13846,14 @@ class ReportExportCollectorService {
13513
13846
  return of({ component, data: null, hiddenColumns: [] });
13514
13847
  }
13515
13848
  const filters = snapshot.filters[component.id] ?? [];
13849
+ if (component.type === 'grid') {
13850
+ return this._gridTable(report, component, filters, snapshot.states[component.id] ?? {}, listRows, snapshot);
13851
+ }
13516
13852
  // The tile's own state, the one its screen and its CSV carry
13517
- // (tileDataState): a time-series chart re-buckets at the viewer's grain
13518
- // and keeps its drill path, a list reads in the screen's order and holds
13519
- // the screen's rows. A list adds its export paging.
13853
+ // (tileDataState): a tile that follows the group-by re-buckets at the
13854
+ // viewer's grain, a time-series chart keeps its drill path, a list reads
13855
+ // in the screen's order and holds the screen's rows. A list adds its
13856
+ // export paging.
13520
13857
  const tileState = snapshot.states[component.id] ?? {};
13521
13858
  const state = component.type === 'list'
13522
13859
  ? { ...tileState, page: 1, pageSize: listRows }
@@ -13531,6 +13868,47 @@ class ReportExportCollectorService {
13531
13868
  return this._reportData.componentData(report.id, component.id, filters, state, true, { handlers: false })
13532
13869
  .pipe(map$1((data) => this._collected(component, data, state.search, hiddenColumns, snapshot)), catchError(() => of({ component, data: null, hiddenColumns })));
13533
13870
  }
13871
+ // A tile's state as an export reads it: tileDataState, and for a table the
13872
+ // layout the viewer put it in, absent while it shows its stored one.
13873
+ _tileState(report, tile, state) {
13874
+ const layout = tile.type === 'grid' ? this._filterState.gridSessionLayout(tile) : null;
13875
+ return { ...tileDataState(report, tile, state), ...(layout ? { layout } : {}) };
13876
+ }
13877
+ // A table tile as the screen draws it, read apart from the screen through
13878
+ // an export source (readGridTable()): in the viewer's layout with the
13879
+ // groups they left open, at most `rows` lines and GRID_EXPORT_COLUMNS
13880
+ // pivot columns, each value written by the screen's formatter once the
13881
+ // read's report line is in. That line stands in as its data, so the title,
13882
+ // the subtitle and the notes read it as a list's. A failed read is a
13883
+ // failed tile.
13884
+ _gridTable(report, component, filters, state, rows, snapshot) {
13885
+ const locale = snapshot.formatContext.locale;
13886
+ const source = new ReportGridSource(this._reportData, report.id, component.id, { isExport: true, locale: () => locale });
13887
+ const screen = () => reportGridFormatter({
13888
+ format: snapshot.formatContext,
13889
+ timezone: this._filterState.timezone(),
13890
+ report: source.report(),
13891
+ });
13892
+ const formatter = {
13893
+ format: (value, field, placement) => screen().format(value, field, placement),
13894
+ toNumber: (text, field, placement) => screen().toNumber(text, field, placement),
13895
+ };
13896
+ const layout = state.layout ?? authoredGridLayout(component);
13897
+ const params = {
13898
+ filters,
13899
+ ...(state.matchAnyGroupIds?.length ? { matchAnyGroupIds: state.matchAnyGroupIds } : {}),
13900
+ ...(state.frequency ? { frequency: state.frequency } : {}),
13901
+ };
13902
+ return readGridTable(source, layout, params, snapshot.gridExpansions?.[component.id], { rows, columns: GRID_EXPORT_COLUMNS }, formatter)
13903
+ .pipe(map$1((table) => ({
13904
+ component,
13905
+ data: { ...(source.report() ?? {}), columns: [], rows: [] },
13906
+ hiddenColumns: [],
13907
+ table,
13908
+ tableNotes: gridExportNotes(table, layout, locale),
13909
+ ...((layout.filters ?? []).length ? { filtered: true } : {}),
13910
+ })), catchError(() => of({ component, data: null, hiddenColumns: [] })));
13911
+ }
13534
13912
  // One tile's answer as the renderers take it. A search the server could
13535
13913
  // not apply (searchNote: the list has no text column) narrowed nothing, so
13536
13914
  // the item does not carry it. A drilled chart carries the breadcrumb of the
@@ -13675,7 +14053,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
13675
14053
  // in the report's format context, as on screen.
13676
14054
  async function renderChartImage(component, data, width, height, context, pixelRatio = 3) {
13677
14055
  // The same tree-shaken echarts build + 'report' theme the live canvas uses.
13678
- const echarts = (await import('./firestitch-report-echarts-CoaGXKET.mjs')).default;
14056
+ const echarts = (await import('./firestitch-report-echarts-EecIloKo.mjs')).default;
13679
14057
  const host = document.createElement('div');
13680
14058
  host.style.width = `${width}px`;
13681
14059
  host.style.height = `${height}px`;
@@ -13995,6 +14373,8 @@ const LIST_INDENT = 16;
13995
14373
  // What the export's overflow gate says the PDF does with a text block that does
13996
14374
  // not fit its box.
13997
14375
  const PDF_OVERFLOW_NOTE = 'The PDF cuts it off at the bottom of its box.';
14376
+ // How far a table's group label steps in per level, points.
14377
+ const GRID_INDENT$1 = 8;
13998
14378
  // Builds the PDF client-side with jspdf. The report's unit is inches and PDFs
13999
14379
  // are points at exactly 72/inch, so each report page becomes one PDF page of
14000
14380
  // the exact physical size and components land at their precise positions.
@@ -14436,7 +14816,7 @@ class ReportPdfService {
14436
14816
  const y = component.y * PT;
14437
14817
  const w = component.w * PT;
14438
14818
  const h = component.h * PT;
14439
- const title = exportTitle(component, tokens, item.search, item.crumbs);
14819
+ const title = exportTitle(component, tokens, item.search, item.crumbs, item.filtered);
14440
14820
  if (title) {
14441
14821
  this._componentTitle(doc, title, x, y, w);
14442
14822
  }
@@ -14457,7 +14837,7 @@ class ReportPdfService {
14457
14837
  kept.forEach((line, index) => {
14458
14838
  this._smallLine(doc, this._pdfText(line), x, bodyY + bodyH + index * NOTE_LINE_H$1, w, 'left', 'italic');
14459
14839
  });
14460
- if (!item.data || !item.data.rows?.length) {
14840
+ if (!item.data || isEmptyTile(item)) {
14461
14841
  this._boxNote(doc, emptyLabel(item), x, bodyY, w, bodyH);
14462
14842
  return;
14463
14843
  }
@@ -14468,6 +14848,7 @@ class ReportPdfService {
14468
14848
  break;
14469
14849
  }
14470
14850
  case 'list':
14851
+ case 'grid':
14471
14852
  this._renderTable(context, item, x, bodyY, w, bodyH, overflows);
14472
14853
  break;
14473
14854
  case 'kpi':
@@ -14520,12 +14901,8 @@ class ReportPdfService {
14520
14901
  // A totals row closes the table once: in the box when every row fits it,
14521
14902
  // else after the last continuation row. Points throughout.
14522
14903
  _renderTable(context, item, x, y, w, h, overflows) {
14523
- const { component, search, hiddenColumns } = item;
14524
- const data = item.data;
14525
- const head = listHeadings(component, data, context.formatContext.locale, hiddenColumns)
14526
- .map((heading) => this._pdfText(heading));
14527
- const { cells, numeric, foot } = this._tableCells(context, component, data, hiddenColumns);
14528
- const omitted = Math.max(0, (data.paging?.total ?? cells.length) - cells.length);
14904
+ const { component, search, filtered } = item;
14905
+ const { head, cells, numeric, foot, omitted } = item.table ? this._gridCells(item) : this._listCells(context, item);
14529
14906
  const bottom = context.pageH - (y + h);
14530
14907
  const { fit, columnStyles } = this._measure(context, head, cells, numeric, foot, x, y, w, bottom);
14531
14908
  if (fit === cells.length && omitted === 0) {
@@ -14538,16 +14915,46 @@ class ReportPdfService {
14538
14915
  // page.
14539
14916
  const kept = Math.max(0, fit - 1);
14540
14917
  overflows.push({
14541
- component, search, crumbs: item.crumbs, head, numeric, rows: cells.slice(kept), omitted, foot, x, w, columnStyles,
14918
+ component, search, crumbs: item.crumbs, filtered, head, numeric, rows: cells.slice(kept), omitted, foot, x, w, columnStyles,
14542
14919
  });
14543
14920
  if (fit >= 1) {
14544
- const body = [...cells.slice(0, kept), this._noticeRow(CONTINUED_NOTICE, head.length, true)];
14921
+ const body = [...cells.slice(0, kept), this._noticeRow(CONTINUED_NOTICE, numeric.length, true)];
14545
14922
  this._drawTable(context, this._tableOptions(context, head, numeric, body, x, y, w, bottom, columnStyles));
14546
14923
  }
14547
14924
  else {
14548
14925
  this._boxNote(context.doc, CONTINUED_NOTICE, x, y, w, h);
14549
14926
  }
14550
14927
  }
14928
+ // A list's heading, rows, totals row and the rows past its fetch.
14929
+ _listCells(context, item) {
14930
+ const { component, hiddenColumns } = item;
14931
+ const data = item.data;
14932
+ const head = [listHeadings(component, data, context.formatContext.locale, hiddenColumns).map((heading) => this._pdfText(heading))];
14933
+ const { cells, numeric, foot } = this._tableCells(context, component, data, hiddenColumns);
14934
+ return { head, cells, numeric, foot, omitted: Math.max(0, (data.paging?.total ?? cells.length) - cells.length) };
14935
+ }
14936
+ // A table tile's heading rows (spanned as the screen's header), its lines
14937
+ // (groups and totals bold, a group's label stepped in by its depth) and its
14938
+ // total row, as gridExportTable() lays them out, and the lines past its cap.
14939
+ _gridCells(item) {
14940
+ const table = gridExportTable(item.table);
14941
+ const cell = (exported) => ({
14942
+ content: this._pdfText(exported.text),
14943
+ colSpan: exported.colSpan,
14944
+ rowSpan: exported.rowSpan,
14945
+ styles: {
14946
+ ...(exported.bold ? { fontStyle: 'bold' } : {}),
14947
+ ...(exported.indent ? { cellPadding: { top: 3, right: 3, bottom: 3, left: 3 + exported.indent * GRID_INDENT$1 } } : {}),
14948
+ },
14949
+ });
14950
+ return {
14951
+ head: table.head.map((row) => row.map(cell)),
14952
+ cells: table.body.map((row) => row.map(cell)),
14953
+ numeric: table.numeric,
14954
+ foot: table.foot ? table.foot.map((exported) => this._pdfText(exported.text)) : null,
14955
+ omitted: table.moreRows,
14956
+ };
14957
+ }
14551
14958
  // Each fetched row as the strings its table cells print, in the report's
14552
14959
  // zone, locale and currency, which columns hold formatted numbers, and the
14553
14960
  // totals row (null without one). The columns the viewer hid are left out.
@@ -14574,7 +14981,7 @@ class ReportPdfService {
14574
14981
  _tableOptions(context, head, numeric, body, x, startY, w, bottom, columnStyles, foot) {
14575
14982
  return {
14576
14983
  theme: 'grid',
14577
- head: [head],
14984
+ head,
14578
14985
  body,
14579
14986
  startY,
14580
14987
  margin: { left: x, right: context.pageW - x - w, bottom },
@@ -14686,7 +15093,7 @@ class ReportPdfService {
14686
15093
  const bottom = this._continuationMargin(bands.footerHeight);
14687
15094
  const { x, w } = overflow;
14688
15095
  const body = overflow.omitted > 0
14689
- ? [...overflow.rows, this._noticeRow(moreRowsNotice(overflow.omitted), overflow.head.length, false)]
15096
+ ? [...overflow.rows, this._noticeRow(moreRowsNotice(overflow.omitted), overflow.numeric.length, false)]
14690
15097
  : overflow.rows;
14691
15098
  // The document's pages the table draws on, in order.
14692
15099
  const pages = [];
@@ -14699,7 +15106,7 @@ class ReportPdfService {
14699
15106
  willDrawPage: () => {
14700
15107
  pages.push(doc.getCurrentPageInfo().pageNumber);
14701
15108
  const tokens = this._tokens(context, page, index, pageNumber + pages.length);
14702
- this._componentTitle(doc, this._continuedTitle(overflow.component, tokens, overflow.search, overflow.crumbs), x, top, w);
15109
+ this._componentTitle(doc, this._continuedTitle(overflow.component, tokens, overflow.search, overflow.crumbs, overflow.filtered), x, top, w);
14703
15110
  },
14704
15111
  });
14705
15112
  await this._renderContinuationMasters(context, page, index, pageNumber, pages);
@@ -14722,8 +15129,8 @@ class ReportPdfService {
14722
15129
  _continuationMargin(band) {
14723
15130
  return band ? band * PT + CONTINUATION_GAP : NO_BAND_MARGIN;
14724
15131
  }
14725
- _continuedTitle(component, tokens, search, crumbs) {
14726
- const title = exportTitle(component, tokens, search, crumbs);
15132
+ _continuedTitle(component, tokens, search, crumbs, filtered) {
15133
+ const title = exportTitle(component, tokens, search, crumbs, filtered);
14727
15134
  return title ? `${title} (continued)` : 'Continued';
14728
15135
  }
14729
15136
  // A KPI tile: the card the screen draws (kpi-card.ts decides every line),
@@ -14963,6 +15370,9 @@ const PPTX_RANK_WHOLE_STEP_PEAK = 10;
14963
15370
  const PPTX_RANK_FORMAT_CODE = '0';
14964
15371
  // Approximate table row height, inches — drives the rows-that-fit cap.
14965
15372
  const SLIDE_ROW_H = 0.26;
15373
+ // The spaces a table's group label steps in by per level: a cell's text is
15374
+ // the one place a slide table indents.
15375
+ const GRID_INDENT = '\u00A0\u00A0\u00A0';
14966
15376
  // Rows fetched for a list's slide table. A slide table never continues onto
14967
15377
  // another slide, so this only has to cover the rows a box can show; the
14968
15378
  // moreRowsNotice() row names the rest.
@@ -15350,7 +15760,7 @@ class ReportPptxService {
15350
15760
  const component = item.component;
15351
15761
  // Geometry is in inches — PowerPoint's native unit.
15352
15762
  const { x, y, w, h } = component;
15353
- const title = exportTitle(component, tokens, item.search, item.crumbs);
15763
+ const title = exportTitle(component, tokens, item.search, item.crumbs, item.filtered);
15354
15764
  if (title) {
15355
15765
  slide.addText(title, {
15356
15766
  x, y, w, h: TITLE_H,
@@ -15380,7 +15790,7 @@ class ReportPptxService {
15380
15790
  fontSize: SMALL_FONT_SIZE, color: MUTED_COLOR, valign: 'top', margin: 0, fit: 'none',
15381
15791
  });
15382
15792
  }
15383
- if (!item.data || !item.data.rows?.length) {
15793
+ if (!item.data || isEmptyTile(item)) {
15384
15794
  slide.addText(emptyLabel(item), {
15385
15795
  x, y: bodyY, w, h: bodyH,
15386
15796
  fontSize: 10, italic: true, color: MUTED_COLOR, align: 'center', valign: 'middle',
@@ -15394,6 +15804,9 @@ class ReportPptxService {
15394
15804
  case 'list':
15395
15805
  this._renderTable(slide, component, item.data, item.hiddenColumns, timezone, context, x, bodyY, w, bodyH);
15396
15806
  break;
15807
+ case 'grid':
15808
+ this._renderGridTable(slide, item, context, x, bodyY, w, bodyH);
15809
+ break;
15397
15810
  case 'kpi':
15398
15811
  this._renderKpi(pptx, slide, item, timezone, context, x, bodyY, w, bodyH);
15399
15812
  break;
@@ -15993,6 +16406,43 @@ class ReportPptxService {
15993
16406
  valign: 'middle',
15994
16407
  });
15995
16408
  }
16409
+ // A table tile as a native table (gridExportTable()): its heading rows
16410
+ // spanned as the screen's header, as many lines as the box holds (groups
16411
+ // and totals bold, a group's label stepped in by its depth), the
16412
+ // moreRowsNotice row for the rest and the total row. A number is written
16413
+ // in the report's locale.
16414
+ _renderGridTable(slide, item, context, x, y, w, h) {
16415
+ const table = gridExportTable(item.table);
16416
+ const cell = (exported, options = {}) => ({
16417
+ text: `${GRID_INDENT.repeat(exported.indent)}${exported.text}`,
16418
+ options: pptxRunOptions({
16419
+ ...(exported.colSpan > 1 ? { colspan: exported.colSpan } : {}),
16420
+ ...(exported.rowSpan > 1 ? { rowspan: exported.rowSpan } : {}),
16421
+ ...(exported.bold ? { bold: true } : {}),
16422
+ ...(exported.numeric ? { align: 'right' } : {}),
16423
+ ...options,
16424
+ }, exported.numeric ? context.locale : undefined),
16425
+ });
16426
+ const cap = Math.max(1, Math.floor(h / SLIDE_ROW_H) - table.head.length - (table.foot ? 1 : 0));
16427
+ const lines = table.body.slice(0, cap);
16428
+ const remaining = table.body.length - lines.length + table.moreRows;
16429
+ slide.addTable([
16430
+ ...table.head.map((row) => row.map((heading) => cell(heading, { color: 'FFFFFF', fill: { color: '4E79A7' } }))),
16431
+ ...lines.map((row) => row.map((line) => cell(line))),
16432
+ ...(remaining > 0
16433
+ ? [[{ text: moreRowsNotice(remaining), options: { colspan: table.numeric.length, italic: true, color: MUTED_COLOR } }]]
16434
+ : []),
16435
+ ...(table.foot ? [table.foot.map((total) => cell(total, { bold: true, fill: { color: 'E4E7EB' } }))] : []),
16436
+ ], {
16437
+ x, y, w,
16438
+ fontSize: 9,
16439
+ color: '1F2933',
16440
+ border: { type: 'solid', pt: 0.5, color: 'E4E7EB' },
16441
+ autoPage: false,
16442
+ rowH: SLIDE_ROW_H,
16443
+ valign: 'middle',
16444
+ });
16445
+ }
15996
16446
  // A KPI tile: the card the screen draws (kpi-card.ts decides every line),
15997
16447
  // as native text boxes and shapes stacked and centered in the body. A card
15998
16448
  // with only its value and label keeps the tile's long-standing single text
@@ -16619,7 +17069,7 @@ class ReportComponent {
16619
17069
  const filters = this._filterState.resolveForReport(report, pageId);
16620
17070
  const frequency = this._filterState.frequency();
16621
17071
  const snapshot = this._collector.snapshot(report);
16622
- const states = zipExportStates(report, snapshot.state, pageId);
17072
+ const states = zipExportStates(report, snapshot.state, pageId, (component) => this._filterState.gridSessionLayout(component));
16623
17073
  this._process.download('Exporting data', this._collector.exportWording(report, snapshot, pageId)
16624
17074
  .pipe(switchMap(({ lines, titles }) => this._reportData.exportReport(report.id, filters, pageId, frequency, lines, states, titles))));
16625
17075
  }
@@ -16808,7 +17258,7 @@ class ReportComponent {
16808
17258
  ReportExportService,
16809
17259
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
16810
17260
  // this route's chunk.
16811
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CoaGXKET.mjs').then((module) => module.default) }),
17261
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-EecIloKo.mjs').then((module) => module.default) }),
16812
17262
  // The bar's date pickers start their weeks on Monday and list the built-in
16813
17263
  // presets; a preset only sets the days.
16814
17264
  provideReportDatePicker(),
@@ -16829,7 +17279,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
16829
17279
  ReportExportService,
16830
17280
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
16831
17281
  // this route's chunk.
16832
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CoaGXKET.mjs').then((module) => module.default) }),
17282
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-EecIloKo.mjs').then((module) => module.default) }),
16833
17283
  // The bar's date pickers start their weeks on Monday and list the built-in
16834
17284
  // presets; a preset only sets the days.
16835
17285
  provideReportDatePicker(),
@@ -18491,7 +18941,7 @@ class FsAiReportComponent {
18491
18941
  const filters = this._filterState.resolveForReport(report, pageId);
18492
18942
  const frequency = this._filterState.frequency();
18493
18943
  const snapshot = this._collector.snapshot(report);
18494
- const states = zipExportStates(report, snapshot.state, pageId);
18944
+ const states = zipExportStates(report, snapshot.state, pageId, (component) => this._filterState.gridSessionLayout(component));
18495
18945
  this._process.download('Exporting data', this._collector.exportWording(report, snapshot, pageId)
18496
18946
  .pipe(switchMap(({ lines, titles }) => this._reportData.exportReport(report.id, filters, pageId, frequency, lines, states, titles))));
18497
18947
  }
@@ -18805,7 +19255,7 @@ class FsAiReportComponent {
18805
19255
  ReportAiChatData,
18806
19256
  { provide: FsAiChatData, useExisting: ReportAiChatData },
18807
19257
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
18808
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CoaGXKET.mjs').then((module) => module.default) }),
19258
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-EecIloKo.mjs').then((module) => module.default) }),
18809
19259
  // The bar's date pickers start their weeks on Monday and list the built-in
18810
19260
  // presets; a preset only sets the days.
18811
19261
  provideReportDatePicker(),
@@ -18829,7 +19279,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
18829
19279
  ReportAiChatData,
18830
19280
  { provide: FsAiChatData, useExisting: ReportAiChatData },
18831
19281
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
18832
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CoaGXKET.mjs').then((module) => module.default) }),
19282
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-EecIloKo.mjs').then((module) => module.default) }),
18833
19283
  // The bar's date pickers start their weeks on Monday and list the built-in
18834
19284
  // presets; a preset only sets the days.
18835
19285
  provideReportDatePicker(),
@@ -18869,4 +19319,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
18869
19319
  */
18870
19320
 
18871
19321
  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 };
18872
- //# sourceMappingURL=firestitch-report-firestitch-report-CHPA7sla.mjs.map
19322
+ //# sourceMappingURL=firestitch-report-firestitch-report-DbdLxQSE.mjs.map