@firestitch/report 18.0.23 → 18.0.25

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.
@@ -398,6 +398,40 @@ function pageGeometry(report, page) {
398
398
  const PAGE_DIMENSION_MIN = 1;
399
399
  const PAGE_DIMENSION_MAX = 50;
400
400
 
401
+ // The assembled report structure returned by GET /api/reports/:id — composed
402
+ // server-side from the normalized tables (reports → report_pages →
403
+ // report_components + report_filters, plus report_filter_groups). All keys are
404
+ // camelCase; component `config` is the standard format the renderer maps to
405
+ // ECharts options / FsList config.
406
+ // How each granularity reads in the group-by control and on an export cover
407
+ // sheet. WHICH of them a given report offers comes from its ReportGroupBy;
408
+ // this is only the naming. 'year' reads as "Annually".
409
+ const GRANULARITY_OPTIONS = [
410
+ { value: 'day', name: 'Day' },
411
+ { value: 'week', name: 'Week' },
412
+ { value: 'month', name: 'Month' },
413
+ { value: 'quarter', name: 'Quarter' },
414
+ { value: 'year', name: 'Annually' },
415
+ ];
416
+ var ReportNodeType;
417
+ (function (ReportNodeType) {
418
+ ReportNodeType["Folder"] = "folder";
419
+ ReportNodeType["Report"] = "report";
420
+ })(ReportNodeType || (ReportNodeType = {}));
421
+ // Reports are soft-deletable: a deleted report leaves the listing but keeps its
422
+ // pages and components, and the listing's "Show Deleted" mode restores it.
423
+ // Folders have no equivalent — they are only deletable once empty, so they hard
424
+ // delete and there is nothing to restore.
425
+ var ReportState;
426
+ (function (ReportState) {
427
+ ReportState["Active"] = "active";
428
+ ReportState["Deleted"] = "deleted";
429
+ })(ReportState || (ReportState = {}));
430
+ const ReportStates = [
431
+ { name: 'Active', value: ReportState.Active },
432
+ { name: 'Deleted', value: ReportState.Deleted },
433
+ ];
434
+
401
435
  // A date-range boundary as the picked LOCAL calendar day (no time, no tz).
402
436
  // Date-range filters are calendar-day semantics, so the boundary must carry
403
437
  // only Y-M-D — never a timezone-bearing instant the backend would shift.
@@ -419,6 +453,12 @@ function dateBoundString(value) {
419
453
  // the group-keyed path for charts/KPIs and the report bar.)
420
454
  // The query key fs-filter uses for a group's item(s).
421
455
  const itemName = (group) => `g${group.id}`;
456
+ // The query key for the report's group-by control. Fixed rather than derived
457
+ // from an id because a report has exactly one — and it is NOT a filter group,
458
+ // so it is read straight off the change query instead of through
459
+ // groupValuesFromQuery. Client-side only: the grain travels to the server as
460
+ // the data request's `state.frequency`.
461
+ const GROUP_BY_ITEM = 'groupBy';
422
462
  // A select group's distinct options, as { name, value } pairs. The server sends
423
463
  // { value, label }: the value is what the filter matches on (an entity's stable
424
464
  // id) and the label is what the viewer reads — so a renamed entity keeps its id
@@ -500,6 +540,65 @@ function filterItemForGroup(group, reportData, reportId, initial) {
500
540
  };
501
541
  }
502
542
  }
543
+ // Whether the report has a chart the group-by control can re-bucket. A time
544
+ // x-axis is the whole qualification: the control changes the grain of that
545
+ // axis, and a category axis has no grain to change.
546
+ function hasTimeSeriesChart(report) {
547
+ return (report?.pages ?? []).some((page) => (page.components ?? []).some((component) => component.type === 'chart' && component.config?.xAxis?.kind === 'time'));
548
+ }
549
+ // The grains the report's group-by control offers, named for the viewer. WHICH
550
+ // of them comes from the report; how they read comes from GRANULARITY_OPTIONS.
551
+ // An empty stored list means every grain — the same rule the backend applies.
552
+ function groupByOptions(report) {
553
+ const granularities = report.config?.groupBy?.granularities ?? [];
554
+ return granularities.length
555
+ ? GRANULARITY_OPTIONS.filter((option) => granularities.includes(option.value))
556
+ : GRANULARITY_OPTIONS;
557
+ }
558
+ // Whether a grain is one the report's control still offers. A grain it doesn't
559
+ // cannot stay in effect: the agent can narrow the list at any time, and a chart
560
+ // left bucketing at a dropped grain is one the viewer can neither see nor change.
561
+ function isGranularityOffered(report, granularity) {
562
+ return !granularity || groupByOptions(report).some((option) => option.value === granularity);
563
+ }
564
+ // The fs-filter config item for the report's group-by control, or null when the
565
+ // report has none to show.
566
+ //
567
+ // It leads the same bar the filters render in, but it is NOT one: a filter
568
+ // narrows WHICH rows are counted, this changes how the surviving rows are
569
+ // BUCKETED — and it comes first because the grain is what a viewer settles
570
+ // before deciding which rows they want.
571
+ // Picking a grain sets a session-wide override that rides along in each chart's
572
+ // data request, so the charts themselves are never rewritten.
573
+ function groupByFilterItem(report, initial) {
574
+ const groupBy = report.config?.groupBy;
575
+ // Only an EXPLICIT false hides the control. An absent block means a report
576
+ // served by a build that predates the control being stored, and every one of
577
+ // those had one — the backend resolves it the same way (ReportGroupBy).
578
+ if (groupBy?.enabled === false || !hasTimeSeriesChart(report)) {
579
+ return null;
580
+ }
581
+ const values = groupByOptions(report);
582
+ return {
583
+ name: GROUP_BY_ITEM,
584
+ type: ItemType.Select,
585
+ label: groupBy?.label || 'Group By',
586
+ multiple: false,
587
+ values: () => values,
588
+ default: isGranularityOffered(report, initial ?? null) ? (initial ?? undefined) : undefined,
589
+ };
590
+ }
591
+ // The group-by control's contribution to the toolbar's fs-filter key. fs-filter
592
+ // reads its config once at init, and the agent can change the control's label
593
+ // or the grains it offers — without those in the key the bar keeps rendering
594
+ // the control it was created with until a full page reload.
595
+ function groupBySignature(report) {
596
+ if (!report || !groupByFilterItem(report)) {
597
+ return '';
598
+ }
599
+ const groupBy = report.config?.groupBy;
600
+ return ['groupBy', groupBy?.label ?? '', (groupBy?.granularities ?? []).join(',')].join(':');
601
+ }
503
602
  // Read an fs-filter change query into per-group values. Returns one entry per
504
603
  // group with the value to store (or null to clear) — callers feed these to
505
604
  // ReportFilterStateService.setValue.
@@ -579,10 +678,11 @@ class ReportFilterStateService {
579
678
  _values = new Map();
580
679
  _groups = new Map();
581
680
  _sessionDisabled = new Set();
582
- // The runtime Frequency control: a single session-wide bucket override for all
681
+ // The report's group-by control: a single session-wide bucket override for all
583
682
  // time-series charts. Unset = each chart uses its authored granularity. Never
584
683
  // persisted (it dies with this report view), and carried in the data request's
585
- // `state`, so it propagates through to the PDF/PowerPoint exports too.
684
+ // `state`, so it propagates through to the PDF/PowerPoint exports too. Named
685
+ // for the `frequency` wire key it travels under, not for the control's label.
586
686
  _frequency = null;
587
687
  _frequencyChanged$ = new Subject();
588
688
  // Emits the group id that changed; components listen filtered to their own
@@ -633,7 +733,7 @@ class ReportFilterStateService {
633
733
  }
634
734
  this._changed$.next(groupId);
635
735
  }
636
- // The active Frequency override, or null when the viewer hasn't chosen one.
736
+ // The active group-by grain, or null when the viewer hasn't chosen one.
637
737
  frequency() {
638
738
  return this._frequency;
639
739
  }
@@ -641,9 +741,9 @@ class ReportFilterStateService {
641
741
  this._frequency = frequency;
642
742
  this._frequencyChanged$.next();
643
743
  }
644
- // Fires whenever the Frequency changes — every time-series chart listens so it
645
- // refetches and re-buckets (the override isn't scoped to any filter group, so
646
- // changesFor() can't carry it).
744
+ // Fires whenever the group-by grain changes — every time-series chart listens
745
+ // so it refetches and re-buckets (the override isn't scoped to any filter
746
+ // group, so changesFor() can't carry it).
647
747
  frequencyChanges() {
648
748
  return this._frequencyChanged$.asObservable();
649
749
  }
@@ -782,7 +882,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
782
882
  type: Injectable
783
883
  }] });
784
884
 
785
- const BUCKET_GRANULARITIES = ['day', 'week', 'month', 'quarter', 'year'];
786
885
  // colorBy applies only to single-dimension bar charts, and only when splitBy
787
886
  // isn't already driving multiple series: it colors each bar by its category and
788
887
  // shows a legend (one series per category, overlapped to full width).
@@ -1006,7 +1105,9 @@ function numeric(value) {
1006
1105
  // The effective time bucket the rows were grouped into (from the data endpoint),
1007
1106
  // or null when the x-axis isn't a time axis — gates the compact period labels.
1008
1107
  function bucketGranularity(data) {
1009
- return data.granularity && BUCKET_GRANULARITIES.includes(data.granularity) ? data.granularity : null;
1108
+ return data.granularity && GRANULARITY_OPTIONS.some((option) => option.value === data.granularity)
1109
+ ? data.granularity
1110
+ : null;
1010
1111
  }
1011
1112
  // Whether the period-start categories cross a year boundary. When they don't,
1012
1113
  // the year is pure noise on every label, so it's dropped entirely.
@@ -1444,7 +1545,7 @@ class ReportComponentComponent {
1444
1545
  this._filterState.changesFor(this.component)
1445
1546
  .pipe(takeUntilDestroyed(this._destroyRef))
1446
1547
  .subscribe(() => this._refresh$.next());
1447
- // A Frequency change re-buckets every time-series chart; it isn't scoped
1548
+ // A group-by change re-buckets every time-series chart; it isn't scoped
1448
1549
  // to a filter group, so it rides its own stream.
1449
1550
  if (this._isTimeSeries) {
1450
1551
  this._filterState.frequencyChanges()
@@ -1688,7 +1789,7 @@ class ReportComponentComponent {
1688
1789
  h: this.component.h,
1689
1790
  });
1690
1791
  }
1691
- // A chart on a time x-axis — the only component the runtime Frequency control
1792
+ // A chart on a time x-axis — the only component the report's group-by control
1692
1793
  // applies to.
1693
1794
  get _isTimeSeries() {
1694
1795
  return this.component.type === 'chart' && this.component.config?.xAxis?.kind === 'time';
@@ -1743,8 +1844,8 @@ class ReportComponentComponent {
1743
1844
  this.error = null;
1744
1845
  this._cdRef.markForCheck();
1745
1846
  const filters = this._filterState.resolveForComponent(this.component);
1746
- // Time-series charts carry the runtime Frequency override (if chosen) so the
1747
- // backend re-buckets; everything else sends no interaction state here.
1847
+ // Time-series charts carry the group-by grain (if the viewer picked one) so
1848
+ // the backend re-buckets; everything else sends no interaction state here.
1748
1849
  const state = this._isTimeSeries && this._filterState.frequency()
1749
1850
  ? { frequency: this._filterState.frequency() }
1750
1851
  : {};
@@ -1764,7 +1865,7 @@ class ReportComponentComponent {
1764
1865
  });
1765
1866
  }
1766
1867
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1767
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportComponentComponent, isStandalone: true, selector: "app-report-component", inputs: { reportId: "reportId", component: "component", groups: "groups", editMode: "editMode", layout: "layout", zoom: "zoom", snapper: "snapper", selected: "selected" }, outputs: { selectComponent: "selectComponent", positionChanged: "positionChanged", flowReorder: "flowReorder", openSettings: "openSettings" }, host: { properties: { "class.height-auto": "this.heightAuto" } }, viewQueries: [{ propertyName: "chartComponent", first: true, predicate: ComponentChartComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.selected]=\"selected\"\n [style.padding]=\"bodyPadding\"\n (pointerdown)=\"onComponentPointerDown($event)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (touchstart)=\"stopCanvasPan($event)\">\n <!-- The header is an fs-filter: heading template = title (and the edit-mode\n drag handle), actions = the menu, items = component-level filters.\n Lists carry no items here \u2014 they filter through FsList. fs-filter reads\n its config once, so the block is keyed on the component-filter set to be\n recreated when it changes. -->\n @for (key of [filterKey]; track key) {\n <fs-filter\n class=\"component-filter\"\n [config]=\"filterConfig\">\n <ng-template fsFilterHeading>\n <div\n class=\"component-title\"\n (pointerdown)=\"onDragStart($event)\">\n {{ component.title }}\n @if (truncated) {\n <mat-icon\n class=\"truncated-icon\"\n matTooltip=\"Showing a truncated result \u2014 refine the filters to see everything.\">\n warning_amber\n </mat-icon>\n }\n </div>\n </ng-template>\n </fs-filter>\n }\n <!-- The actions menu is floated top-right (absolute) rather than living in\n fs-filter's actions slot, so its ~40px button height no longer drives the\n header row's height. It overlays the box corner; the heading reserves\n right padding so a long title ellipsises before it. -->\n <button\n mat-icon-button\n class=\"component-menu\"\n [matMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <mat-menu #menu=\"matMenu\">\n <button mat-menu-item (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </button>\n <button mat-menu-item (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </button>\n </mat-menu>\n <div class=\"component-body\">\n @if (component.type === 'list') {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\"\n [groups]=\"groups\">\n </app-report-component-list>\n } @else if (loading) {\n <div class=\"component-state\">\n <div class=\"loading-shimmer\"></div>\n </div>\n } @else if (error) {\n <div class=\"component-state error\">\n {{ error }}\n </div>\n } @else if (!data?.rows?.length) {\n <div class=\"component-state\">\n No data\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-chart>\n }\n <!-- In edit mode a transparent veil over the body makes the WHOLE\n component draggable (industry standard: you grab the object, not just\n its title bar) and keeps inner widgets from swallowing the gesture. -->\n @if (editMode) {\n <div\n class=\"drag-veil\"\n (pointerdown)=\"onDragStart($event)\">\n </div>\n }\n </div>\n</div>\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\n @if (layout === 'freeform') {\n @for (handle of resizeHandles; track handle) {\n <div\n class=\"handle handle-{{ handle }}\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, handle)\">\n </div>\n }\n } @else {\n <!-- Flow height is always auto (the box fits its content), so only the\n east handle (width %) is offered here. -->\n <div\n class=\"handle handle-e\"\n matTooltip=\"Width (% of page)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, 'e')\">\n </div>\n }\n}", styles: [":host{position:absolute;display:flex;flex-direction:column;font-size:10px}:host.flow-item{position:relative;left:auto;top:auto}:host.flow-dragging{z-index:40;opacity:.7;pointer-events:none}.component-box{position:relative;width:100%;flex:1 1 auto;min-height:0;box-sizing:border-box;display:flex;flex-direction:column;background:#fff;border:1px solid #e4e7eb;border-radius:6px;overflow:hidden;transition:box-shadow .12s ease,border-color .12s ease}.component-box.editing .component-title{cursor:grab}.component-box.editing .component-title:active{cursor:grabbing}.component-box.editing:hover{border-color:#9db3c8;box-shadow:0 2px 8px #0f172a14}.component-box.selected{border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color),0 4px 14px #2196f32e}:host ::ng-deep .component-filter .filter-bar-container{align-items:center!important}.component-filter{flex:0 0 auto;display:block;padding:0 0 4px}.component-filter .component-title{display:flex;align-items:center;gap:4px;-webkit-user-select:none;user-select:none;padding-right:40px;font-size:var(--report-heading-size);font-weight:600;color:#1f2933;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.component-filter .component-title .truncated-icon{font-size:16px;width:16px;height:16px;color:#f59e0b}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}:host(.height-auto) .component-state{min-height:120px}.drag-veil{position:absolute;inset:0;cursor:grab;touch-action:none}.drag-veil:active{cursor:grabbing}.component-state{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:12px;color:#7b8794;padding:8px;text-align:center}.component-state.error{color:#e15759}.loading-shimmer{width:70%;height:60%;border-radius:6px;background:linear-gradient(100deg,#f0f4f8 40%,#e4ebf2,#f0f4f8 60%);background-size:200% 100%;animation:shimmer 1.2s infinite linear}@keyframes shimmer{to{background-position-x:-200%}}.handle{position:absolute;width:14px;height:14px;display:flex;align-items:center;justify-content:center;touch-action:none;z-index:20;transform:scale(calc(1 / var(--canvas-zoom, 1)))}.handle:before{content:\"\";width:8px;height:8px;background:#fff;border:1.5px solid var(--brand-primary-color);border-radius:2px;box-shadow:0 1px 2px #0f172a33}.handle-nw{left:-7px;top:-7px;cursor:nwse-resize}.handle-n{left:calc(50% - 7px);top:-7px;cursor:ns-resize}.handle-ne{right:-7px;top:-7px;cursor:nesw-resize}.handle-e{right:-7px;top:calc(50% - 7px);cursor:ew-resize}.handle-se{right:-7px;bottom:-7px;cursor:nwse-resize}.handle-s{left:calc(50% - 7px);bottom:-7px;cursor:ns-resize}.handle-sw{left:-7px;bottom:-7px;cursor:nesw-resize}.handle-w{left:-7px;top:calc(50% - 7px);cursor:ew-resize}\n"], dependencies: [{ kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i1$1.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i1$1.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i1$1.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "directive", type: i2.FilterHeadingDirective, selector: "[fsFilterHeading]" }, { kind: "component", type: ComponentChartComponent, selector: "app-report-component-chart", inputs: ["component", "data"] }, { kind: "component", type: ComponentKpiComponent, selector: "app-report-component-kpi", inputs: ["component", "data"] }, { kind: "component", type: ComponentListComponent, selector: "app-report-component-list", inputs: ["reportId", "component", "groups"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1868
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportComponentComponent, isStandalone: true, selector: "app-report-component", inputs: { reportId: "reportId", component: "component", groups: "groups", editMode: "editMode", layout: "layout", zoom: "zoom", snapper: "snapper", selected: "selected" }, outputs: { selectComponent: "selectComponent", positionChanged: "positionChanged", flowReorder: "flowReorder", openSettings: "openSettings" }, host: { properties: { "class.height-auto": "this.heightAuto" } }, viewQueries: [{ propertyName: "chartComponent", first: true, predicate: ComponentChartComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.selected]=\"selected\"\n [style.padding]=\"bodyPadding\"\n (pointerdown)=\"onComponentPointerDown($event)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (touchstart)=\"stopCanvasPan($event)\">\n <!-- The header is an fs-filter: heading template = title (and the edit-mode\n drag handle), actions = the menu, items = component-level filters.\n Lists carry no items here \u2014 they filter through FsList. fs-filter reads\n its config once, so the block is keyed on the component-filter set to be\n recreated when it changes. -->\n @for (key of [filterKey]; track key) {\n <fs-filter\n class=\"component-filter\"\n [config]=\"filterConfig\">\n <ng-template fsFilterHeading>\n <div\n class=\"component-title\"\n (pointerdown)=\"onDragStart($event)\">\n {{ component.title }}\n @if (truncated) {\n <mat-icon\n class=\"truncated-icon\"\n matTooltip=\"Showing a truncated result \u2014 refine the filters to see everything.\">\n warning_amber\n </mat-icon>\n }\n </div>\n </ng-template>\n </fs-filter>\n }\n <!-- The actions menu is floated top-right (absolute) rather than living in\n fs-filter's actions slot, so its ~40px button height no longer drives the\n header row's height. It overlays the box corner; the heading reserves\n right padding so a long title ellipsises before it. -->\n <button\n mat-icon-button\n class=\"component-menu\"\n [matMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <mat-menu #menu=\"matMenu\">\n <button mat-menu-item (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </button>\n <button mat-menu-item (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </button>\n </mat-menu>\n <div class=\"component-body\">\n @if (component.type === 'list') {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\"\n [groups]=\"groups\">\n </app-report-component-list>\n } @else if (loading) {\n <div class=\"component-state\">\n <div class=\"loading-shimmer\"></div>\n </div>\n } @else if (error) {\n <div class=\"component-state error\">\n {{ error }}\n </div>\n } @else if (!data?.rows?.length) {\n <div class=\"component-state\">\n No data\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-chart>\n }\n <!-- In edit mode a transparent veil over the body makes the WHOLE\n component draggable (industry standard: you grab the object, not just\n its title bar) and keeps inner widgets from swallowing the gesture. -->\n @if (editMode) {\n <div\n class=\"drag-veil\"\n (pointerdown)=\"onDragStart($event)\">\n </div>\n }\n </div>\n</div>\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\n @if (layout === 'freeform') {\n @for (handle of resizeHandles; track handle) {\n <div\n class=\"handle handle-{{ handle }}\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, handle)\">\n </div>\n }\n } @else {\n <!-- Flow height is always auto (the box fits its content), so only the\n east handle (width %) is offered here. -->\n <div\n class=\"handle handle-e\"\n matTooltip=\"Width (% of page)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, 'e')\">\n </div>\n }\n}", styles: [":host{position:absolute;display:flex;flex-direction:column;font-size:10px;--mdc-outlined-button-container-height: 30px;--mdc-outlined-button-label-text-size: 10px;--mat-outlined-button-horizontal-padding: 15px;--mdc-text-button-container-height: 30px;--mdc-text-button-label-text-size: 10px;--mat-text-button-horizontal-padding: 15px}:host.flow-item{position:relative;left:auto;top:auto}:host.flow-dragging{z-index:40;opacity:.7;pointer-events:none}.component-box{position:relative;width:100%;flex:1 1 auto;min-height:0;box-sizing:border-box;display:flex;flex-direction:column;background:#fff;border:1px solid #e4e7eb;border-radius:6px;overflow:hidden;transition:box-shadow .12s ease,border-color .12s ease}.component-box.editing .component-title{cursor:grab}.component-box.editing .component-title:active{cursor:grabbing}.component-box.editing:hover{border-color:#9db3c8;box-shadow:0 2px 8px #0f172a14}.component-box.selected{border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color),0 4px 14px #2196f32e}:host ::ng-deep .component-filter .filter-bar-container{align-items:center!important}.component-filter{flex:0 0 auto;display:block;padding:0 0 4px}.component-filter .component-title{display:flex;align-items:center;gap:4px;-webkit-user-select:none;user-select:none;padding-right:40px;font-size:var(--report-heading-size);font-weight:600;color:#1f2933;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.component-filter .component-title .truncated-icon{font-size:16px;width:16px;height:16px;color:#f59e0b}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}:host(.height-auto) .component-state{min-height:120px}.drag-veil{position:absolute;inset:0;cursor:grab;touch-action:none}.drag-veil:active{cursor:grabbing}.component-state{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:12px;color:#7b8794;padding:8px;text-align:center}.component-state.error{color:#e15759}.loading-shimmer{width:70%;height:60%;border-radius:6px;background:linear-gradient(100deg,#f0f4f8 40%,#e4ebf2,#f0f4f8 60%);background-size:200% 100%;animation:shimmer 1.2s infinite linear}@keyframes shimmer{to{background-position-x:-200%}}.handle{position:absolute;width:14px;height:14px;display:flex;align-items:center;justify-content:center;touch-action:none;z-index:20;transform:scale(calc(1 / var(--canvas-zoom, 1)))}.handle:before{content:\"\";width:8px;height:8px;background:#fff;border:1.5px solid var(--brand-primary-color);border-radius:2px;box-shadow:0 1px 2px #0f172a33}.handle-nw{left:-7px;top:-7px;cursor:nwse-resize}.handle-n{left:calc(50% - 7px);top:-7px;cursor:ns-resize}.handle-ne{right:-7px;top:-7px;cursor:nesw-resize}.handle-e{right:-7px;top:calc(50% - 7px);cursor:ew-resize}.handle-se{right:-7px;bottom:-7px;cursor:nwse-resize}.handle-s{left:calc(50% - 7px);bottom:-7px;cursor:ns-resize}.handle-sw{left:-7px;bottom:-7px;cursor:nesw-resize}.handle-w{left:-7px;top:calc(50% - 7px);cursor:ew-resize}\n"], dependencies: [{ kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i1$1.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i1$1.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i1$1.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "directive", type: i2.FilterHeadingDirective, selector: "[fsFilterHeading]" }, { kind: "component", type: ComponentChartComponent, selector: "app-report-component-chart", inputs: ["component", "data"] }, { kind: "component", type: ComponentKpiComponent, selector: "app-report-component-kpi", inputs: ["component", "data"] }, { kind: "component", type: ComponentListComponent, selector: "app-report-component-list", inputs: ["reportId", "component", "groups"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1768
1869
  }
1769
1870
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, decorators: [{
1770
1871
  type: Component,
@@ -1777,7 +1878,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
1777
1878
  ComponentChartComponent,
1778
1879
  ComponentKpiComponent,
1779
1880
  ComponentListComponent,
1780
- ], template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.selected]=\"selected\"\n [style.padding]=\"bodyPadding\"\n (pointerdown)=\"onComponentPointerDown($event)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (touchstart)=\"stopCanvasPan($event)\">\n <!-- The header is an fs-filter: heading template = title (and the edit-mode\n drag handle), actions = the menu, items = component-level filters.\n Lists carry no items here \u2014 they filter through FsList. fs-filter reads\n its config once, so the block is keyed on the component-filter set to be\n recreated when it changes. -->\n @for (key of [filterKey]; track key) {\n <fs-filter\n class=\"component-filter\"\n [config]=\"filterConfig\">\n <ng-template fsFilterHeading>\n <div\n class=\"component-title\"\n (pointerdown)=\"onDragStart($event)\">\n {{ component.title }}\n @if (truncated) {\n <mat-icon\n class=\"truncated-icon\"\n matTooltip=\"Showing a truncated result \u2014 refine the filters to see everything.\">\n warning_amber\n </mat-icon>\n }\n </div>\n </ng-template>\n </fs-filter>\n }\n <!-- The actions menu is floated top-right (absolute) rather than living in\n fs-filter's actions slot, so its ~40px button height no longer drives the\n header row's height. It overlays the box corner; the heading reserves\n right padding so a long title ellipsises before it. -->\n <button\n mat-icon-button\n class=\"component-menu\"\n [matMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <mat-menu #menu=\"matMenu\">\n <button mat-menu-item (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </button>\n <button mat-menu-item (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </button>\n </mat-menu>\n <div class=\"component-body\">\n @if (component.type === 'list') {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\"\n [groups]=\"groups\">\n </app-report-component-list>\n } @else if (loading) {\n <div class=\"component-state\">\n <div class=\"loading-shimmer\"></div>\n </div>\n } @else if (error) {\n <div class=\"component-state error\">\n {{ error }}\n </div>\n } @else if (!data?.rows?.length) {\n <div class=\"component-state\">\n No data\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-chart>\n }\n <!-- In edit mode a transparent veil over the body makes the WHOLE\n component draggable (industry standard: you grab the object, not just\n its title bar) and keeps inner widgets from swallowing the gesture. -->\n @if (editMode) {\n <div\n class=\"drag-veil\"\n (pointerdown)=\"onDragStart($event)\">\n </div>\n }\n </div>\n</div>\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\n @if (layout === 'freeform') {\n @for (handle of resizeHandles; track handle) {\n <div\n class=\"handle handle-{{ handle }}\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, handle)\">\n </div>\n }\n } @else {\n <!-- Flow height is always auto (the box fits its content), so only the\n east handle (width %) is offered here. -->\n <div\n class=\"handle handle-e\"\n matTooltip=\"Width (% of page)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, 'e')\">\n </div>\n }\n}", styles: [":host{position:absolute;display:flex;flex-direction:column;font-size:10px}:host.flow-item{position:relative;left:auto;top:auto}:host.flow-dragging{z-index:40;opacity:.7;pointer-events:none}.component-box{position:relative;width:100%;flex:1 1 auto;min-height:0;box-sizing:border-box;display:flex;flex-direction:column;background:#fff;border:1px solid #e4e7eb;border-radius:6px;overflow:hidden;transition:box-shadow .12s ease,border-color .12s ease}.component-box.editing .component-title{cursor:grab}.component-box.editing .component-title:active{cursor:grabbing}.component-box.editing:hover{border-color:#9db3c8;box-shadow:0 2px 8px #0f172a14}.component-box.selected{border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color),0 4px 14px #2196f32e}:host ::ng-deep .component-filter .filter-bar-container{align-items:center!important}.component-filter{flex:0 0 auto;display:block;padding:0 0 4px}.component-filter .component-title{display:flex;align-items:center;gap:4px;-webkit-user-select:none;user-select:none;padding-right:40px;font-size:var(--report-heading-size);font-weight:600;color:#1f2933;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.component-filter .component-title .truncated-icon{font-size:16px;width:16px;height:16px;color:#f59e0b}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}:host(.height-auto) .component-state{min-height:120px}.drag-veil{position:absolute;inset:0;cursor:grab;touch-action:none}.drag-veil:active{cursor:grabbing}.component-state{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:12px;color:#7b8794;padding:8px;text-align:center}.component-state.error{color:#e15759}.loading-shimmer{width:70%;height:60%;border-radius:6px;background:linear-gradient(100deg,#f0f4f8 40%,#e4ebf2,#f0f4f8 60%);background-size:200% 100%;animation:shimmer 1.2s infinite linear}@keyframes shimmer{to{background-position-x:-200%}}.handle{position:absolute;width:14px;height:14px;display:flex;align-items:center;justify-content:center;touch-action:none;z-index:20;transform:scale(calc(1 / var(--canvas-zoom, 1)))}.handle:before{content:\"\";width:8px;height:8px;background:#fff;border:1.5px solid var(--brand-primary-color);border-radius:2px;box-shadow:0 1px 2px #0f172a33}.handle-nw{left:-7px;top:-7px;cursor:nwse-resize}.handle-n{left:calc(50% - 7px);top:-7px;cursor:ns-resize}.handle-ne{right:-7px;top:-7px;cursor:nesw-resize}.handle-e{right:-7px;top:calc(50% - 7px);cursor:ew-resize}.handle-se{right:-7px;bottom:-7px;cursor:nwse-resize}.handle-s{left:calc(50% - 7px);bottom:-7px;cursor:ns-resize}.handle-sw{left:-7px;bottom:-7px;cursor:nesw-resize}.handle-w{left:-7px;top:calc(50% - 7px);cursor:ew-resize}\n"] }]
1881
+ ], template: "<div\n class=\"component-box\"\n [class.editing]=\"editMode\"\n [class.selected]=\"selected\"\n [style.padding]=\"bodyPadding\"\n (pointerdown)=\"onComponentPointerDown($event)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (touchstart)=\"stopCanvasPan($event)\">\n <!-- The header is an fs-filter: heading template = title (and the edit-mode\n drag handle), actions = the menu, items = component-level filters.\n Lists carry no items here \u2014 they filter through FsList. fs-filter reads\n its config once, so the block is keyed on the component-filter set to be\n recreated when it changes. -->\n @for (key of [filterKey]; track key) {\n <fs-filter\n class=\"component-filter\"\n [config]=\"filterConfig\">\n <ng-template fsFilterHeading>\n <div\n class=\"component-title\"\n (pointerdown)=\"onDragStart($event)\">\n {{ component.title }}\n @if (truncated) {\n <mat-icon\n class=\"truncated-icon\"\n matTooltip=\"Showing a truncated result \u2014 refine the filters to see everything.\">\n warning_amber\n </mat-icon>\n }\n </div>\n </ng-template>\n </fs-filter>\n }\n <!-- The actions menu is floated top-right (absolute) rather than living in\n fs-filter's actions slot, so its ~40px button height no longer drives the\n header row's height. It overlays the box corner; the heading reserves\n right padding so a long title ellipsises before it. -->\n <button\n mat-icon-button\n class=\"component-menu\"\n [matMenuTriggerFor]=\"menu\"\n (pointerdown)=\"$event.stopPropagation()\"\n (mousedown)=\"stopCanvasPan($event)\">\n <mat-icon>more_vert</mat-icon>\n </button>\n <mat-menu #menu=\"matMenu\">\n <button mat-menu-item (click)=\"settings()\">\n <mat-icon>tune</mat-icon>\n <span>Settings</span>\n </button>\n <button mat-menu-item (click)=\"exportCsv()\">\n <mat-icon>download</mat-icon>\n <span>Export CSV</span>\n </button>\n </mat-menu>\n <div class=\"component-body\">\n @if (component.type === 'list') {\n <app-report-component-list\n [reportId]=\"reportId\"\n [component]=\"component\"\n [groups]=\"groups\">\n </app-report-component-list>\n } @else if (loading) {\n <div class=\"component-state\">\n <div class=\"loading-shimmer\"></div>\n </div>\n } @else if (error) {\n <div class=\"component-state error\">\n {{ error }}\n </div>\n } @else if (!data?.rows?.length) {\n <div class=\"component-state\">\n No data\n </div>\n } @else if (component.type === 'kpi') {\n <app-report-component-kpi\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-kpi>\n } @else {\n <app-report-component-chart\n [component]=\"component\"\n [data]=\"data\">\n </app-report-component-chart>\n }\n <!-- In edit mode a transparent veil over the body makes the WHOLE\n component draggable (industry standard: you grab the object, not just\n its title bar) and keeps inner widgets from swallowing the gesture. -->\n @if (editMode) {\n <div\n class=\"drag-veil\"\n (pointerdown)=\"onDragStart($event)\">\n </div>\n }\n </div>\n</div>\n<!-- Handles live OUTSIDE the clipped box so they straddle its edges. -->\n@if (editMode && selected) {\n @if (layout === 'freeform') {\n @for (handle of resizeHandles; track handle) {\n <div\n class=\"handle handle-{{ handle }}\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, handle)\">\n </div>\n }\n } @else {\n <!-- Flow height is always auto (the box fits its content), so only the\n east handle (width %) is offered here. -->\n <div\n class=\"handle handle-e\"\n matTooltip=\"Width (% of page)\"\n (mousedown)=\"stopCanvasPan($event)\"\n (pointerdown)=\"onResizeStart($event, 'e')\">\n </div>\n }\n}", styles: [":host{position:absolute;display:flex;flex-direction:column;font-size:10px;--mdc-outlined-button-container-height: 30px;--mdc-outlined-button-label-text-size: 10px;--mat-outlined-button-horizontal-padding: 15px;--mdc-text-button-container-height: 30px;--mdc-text-button-label-text-size: 10px;--mat-text-button-horizontal-padding: 15px}:host.flow-item{position:relative;left:auto;top:auto}:host.flow-dragging{z-index:40;opacity:.7;pointer-events:none}.component-box{position:relative;width:100%;flex:1 1 auto;min-height:0;box-sizing:border-box;display:flex;flex-direction:column;background:#fff;border:1px solid #e4e7eb;border-radius:6px;overflow:hidden;transition:box-shadow .12s ease,border-color .12s ease}.component-box.editing .component-title{cursor:grab}.component-box.editing .component-title:active{cursor:grabbing}.component-box.editing:hover{border-color:#9db3c8;box-shadow:0 2px 8px #0f172a14}.component-box.selected{border-color:var(--brand-primary-color);box-shadow:0 0 0 1px var(--brand-primary-color),0 4px 14px #2196f32e}:host ::ng-deep .component-filter .filter-bar-container{align-items:center!important}.component-filter{flex:0 0 auto;display:block;padding:0 0 4px}.component-filter .component-title{display:flex;align-items:center;gap:4px;-webkit-user-select:none;user-select:none;padding-right:40px;font-size:var(--report-heading-size);font-weight:600;color:#1f2933;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.component-filter .component-title .truncated-icon{font-size:16px;width:16px;height:16px;color:#f59e0b}.component-menu{position:absolute;top:2px;right:2px;z-index:30}.component-body{flex:1 1 auto;min-height:0;position:relative}:host(.height-auto) .component-state{min-height:120px}.drag-veil{position:absolute;inset:0;cursor:grab;touch-action:none}.drag-veil:active{cursor:grabbing}.component-state{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:12px;color:#7b8794;padding:8px;text-align:center}.component-state.error{color:#e15759}.loading-shimmer{width:70%;height:60%;border-radius:6px;background:linear-gradient(100deg,#f0f4f8 40%,#e4ebf2,#f0f4f8 60%);background-size:200% 100%;animation:shimmer 1.2s infinite linear}@keyframes shimmer{to{background-position-x:-200%}}.handle{position:absolute;width:14px;height:14px;display:flex;align-items:center;justify-content:center;touch-action:none;z-index:20;transform:scale(calc(1 / var(--canvas-zoom, 1)))}.handle:before{content:\"\";width:8px;height:8px;background:#fff;border:1.5px solid var(--brand-primary-color);border-radius:2px;box-shadow:0 1px 2px #0f172a33}.handle-nw{left:-7px;top:-7px;cursor:nwse-resize}.handle-n{left:calc(50% - 7px);top:-7px;cursor:ns-resize}.handle-ne{right:-7px;top:-7px;cursor:nesw-resize}.handle-e{right:-7px;top:calc(50% - 7px);cursor:ew-resize}.handle-se{right:-7px;bottom:-7px;cursor:nwse-resize}.handle-s{left:calc(50% - 7px);bottom:-7px;cursor:ns-resize}.handle-sw{left:-7px;bottom:-7px;cursor:nesw-resize}.handle-w{left:-7px;top:calc(50% - 7px);cursor:ew-resize}\n"] }]
1781
1882
  }], propDecorators: { reportId: [{
1782
1883
  type: Input
1783
1884
  }], component: [{
@@ -2628,40 +2729,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
2628
2729
  ], template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n Report Settings\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Name\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"name\"\n name=\"name\"\n [fsFormRequired]=\"true\">\n </mat-form-field>\n <!--\n The page, typed in inches. Orientation is not asked for because it\n is not an independent choice - a page wider than it is tall IS\n landscape - so the two numbers say everything there is to say.\n -->\n <div class=\"fs-row.gap-sm.align-start\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Width\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"0.1\"\n [(ngModel)]=\"pageWidth\"\n name=\"pageWidth\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <span matTextSuffix>\n inches\n </span>\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Height\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"0.1\"\n [(ngModel)]=\"pageHeight\"\n name=\"pageHeight\"\n [fsFormRequired]=\"true\"\n [fsFormMin]=\"pageDimensionMin\"\n [fsFormMax]=\"pageDimensionMax\">\n <span matTextSuffix>\n inches\n </span>\n </mat-form-field>\n </div>\n <mat-form-field>\n <mat-label>\n Layout\n </mat-label>\n <mat-select\n [(ngModel)]=\"layout\"\n name=\"layout\">\n <mat-option value=\"freeform\">\n Freeform \u2014 position components anywhere on the page\n </mat-option>\n <mat-option value=\"flow\">\n Flow \u2014 components flow into rows by width %\n </mat-option>\n </mat-select>\n <mat-hint>\n Freeform is like PowerPoint; Flow is like a responsive dashboard.\n </mat-hint>\n </mat-form-field>\n <fs-ai-report-timezone-select [(timezone)]=\"timezone\"></fs-ai-report-timezone-select>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Styles\"\n name=\"styles\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Heading Size\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"1\"\n min=\"6\"\n max=\"96\"\n [(ngModel)]=\"headingSize\"\n name=\"headingSize\"\n [fsFormMin]=\"6\"\n [fsFormMax]=\"96\">\n <span matTextSuffix>\n pt\n </span>\n <mat-hint>\n Applies to component titles. Points map 1:1 to PDF and PowerPoint.\n </mat-hint>\n </mat-form-field>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"fs-column tab-body\">\n @if (filterRows().length) {\n @for (row of filterRows(); track row.group.id) {\n @switch (row.group.type) {\n @case ('dateRange') {\n <div class=\"fs-row.gap-sm.align-center fs-flex\">\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default From {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.start\"\n [name]=\"'start' + row.group.id\">\n </mat-form-field>\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default To {{ row.label }}\n </mat-label>\n <input\n matInput\n fsDatePicker\n [(ngModel)]=\"row.end\"\n [name]=\"'end' + row.group.id\">\n </mat-form-field>\n </div>\n }\n @case ('select') {\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [label]=\"'Default ' + row.label\"\n [fetch]=\"row.fetch\"\n [(ngModel)]=\"row.values\"\n [name]=\"'values' + row.group.id\"\n [multiple]=\"true\"\n [fetchOnFocus]=\"true\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n </fs-autocomplete-chips>\n }\n @default {\n <mat-form-field class=\"fs-flex\">\n <mat-label>\n Default {{ row.label }}\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"row.value\"\n [name]=\"'value' + row.group.id\">\n </mat-form-field>\n }\n }\n }\n } @else {\n <fs-message-info>\n This report has no report-level filters yet. Add a filter to a\n component (its settings \u2192 Filters) and show it in the report bar,\n then set its default here.\n </fs-message-info>\n }\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <fs-form-dialog-actions>\n <button\n type=\"button\"\n mat-button\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}\n"] }]
2629
2730
  }] });
2630
2731
 
2631
- // The assembled report structure returned by GET /api/reports/:id — composed
2632
- // server-side from the normalized tables (reports → report_pages →
2633
- // report_components + report_filters, plus report_filter_groups). All keys are
2634
- // camelCase; component `config` is the standard format the renderer maps to
2635
- // ECharts options / FsList config.
2636
- // The runtime Frequency control's choices — re-buckets every time-series chart
2637
- // at view time, overriding each chart's authored granularity. 'year' reads as
2638
- // "Annually" in the control.
2639
- const FREQUENCY_OPTIONS = [
2640
- { value: 'day', name: 'Day' },
2641
- { value: 'week', name: 'Week' },
2642
- { value: 'month', name: 'Month' },
2643
- { value: 'quarter', name: 'Quarter' },
2644
- { value: 'year', name: 'Annually' },
2645
- ];
2646
- var ReportNodeType;
2647
- (function (ReportNodeType) {
2648
- ReportNodeType["Folder"] = "folder";
2649
- ReportNodeType["Report"] = "report";
2650
- })(ReportNodeType || (ReportNodeType = {}));
2651
- // Reports are soft-deletable: a deleted report leaves the listing but keeps its
2652
- // pages and components, and the listing's "Show Deleted" mode restores it.
2653
- // Folders have no equivalent — they are only deletable once empty, so they hard
2654
- // delete and there is nothing to restore.
2655
- var ReportState;
2656
- (function (ReportState) {
2657
- ReportState["Active"] = "active";
2658
- ReportState["Deleted"] = "deleted";
2659
- })(ReportState || (ReportState = {}));
2660
- const ReportStates = [
2661
- { name: 'Active', value: ReportState.Active },
2662
- { name: 'Deleted', value: ReportState.Deleted },
2663
- ];
2664
-
2665
2732
  // Rows fetched for a list component's export table — enough to fill a
2666
2733
  // slide/page table; the cap note covers the rest.
2667
2734
  const LIST_EXPORT_ROWS = 100;
@@ -2699,7 +2766,7 @@ class ReportExportCollectorService {
2699
2766
  }
2700
2767
  _componentData(report, component) {
2701
2768
  const filters = this._filterState.resolveForComponent(component);
2702
- // A time-series chart carries the viewer's Frequency override so the export
2769
+ // A time-series chart carries the viewer's group-by grain so the export
2703
2770
  // re-buckets exactly as the screen does; lists carry their export paging.
2704
2771
  const frequency = this._filterState.frequency();
2705
2772
  const isTimeSeries = component.type === 'chart' && component.config?.xAxis?.kind === 'time';
@@ -2733,10 +2800,14 @@ class ReportExportCollectorService {
2733
2800
  lines.push(of(`${label}: "${value.value.trim()}"`));
2734
2801
  }
2735
2802
  }
2736
- const frequency = this._filterState.frequency();
2737
- if (frequency) {
2738
- const option = FREQUENCY_OPTIONS.find((item) => item.value === frequency);
2739
- lines.push(of(`Frequency: ${option?.name ?? frequency}`));
2803
+ // The group-by grain, under the report's own name for the control — a cover
2804
+ // sheet reading "Frequency" when the report calls it "Period" describes a
2805
+ // control the reader cannot find on screen.
2806
+ const granularity = this._filterState.frequency();
2807
+ if (granularity) {
2808
+ const option = GRANULARITY_OPTIONS.find((item) => item.value === granularity);
2809
+ const label = report.config?.groupBy?.label || 'Group By';
2810
+ lines.push(of(`${label}: ${option?.name ?? granularity}`));
2740
2811
  }
2741
2812
  return lines.length ? forkJoin(lines) : of([]);
2742
2813
  }
@@ -2774,7 +2845,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
2774
2845
  // complete synchronously after setOption.
2775
2846
  async function renderChartImage(component, data, width, height, pixelRatio = 3) {
2776
2847
  // The same tree-shaken echarts build + 'report' theme the live canvas uses.
2777
- const echarts = (await import('./firestitch-report-echarts-CWXfRyqK.mjs')).default;
2848
+ const echarts = (await import('./firestitch-report-echarts-6sMh877A.mjs')).default;
2778
2849
  const host = document.createElement('div');
2779
2850
  host.style.width = `${width}px`;
2780
2851
  host.style.height = `${height}px`;
@@ -3228,9 +3299,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
3228
3299
  }]
3229
3300
  }] });
3230
3301
 
3231
- // fs-filter query key for the runtime Frequency control (a render-layer bucket
3232
- // override, not a filter group — so it's read straight off the query).
3233
- const FREQUENCY_ITEM$1 = 'frequency';
3234
3302
  // The Reports page: AI chat on the left, the report canvas on the right —
3235
3303
  // a split-pane layout; the viewer is our own canvas (no iframe, no embed URLs).
3236
3304
  // The chat is always scoped to the selected report; its id rides on every
@@ -3457,11 +3525,10 @@ class ReportComponent {
3457
3525
  .filter((group) => group.level === 'report' || group.level === 'both')
3458
3526
  .sort((a, b) => a.order - b.order);
3459
3527
  }
3460
- // Whether the loaded report has any report-level filters OR a time-series
3461
- // chart (which earns the runtime Frequency control) gates the toolbar
3462
- // fs-filter so it isn't rendered (taking space) when there's nothing to show.
3528
+ // Gates the toolbar fs-filter so it isn't rendered (taking space) when there
3529
+ // is nothing to put in itno report-level filters and no group-by control.
3463
3530
  get reportHasFilters() {
3464
- return this._reportGroups().length > 0 || this._hasTimeSeriesChart();
3531
+ return this._reportGroups().length > 0 || !!this._groupByItem();
3465
3532
  }
3466
3533
  // fs-filter reads its config only once (at init) — re-binding does nothing.
3467
3534
  // The template keys the toolbar fs-filter on this signature so it's recreated
@@ -3472,28 +3539,31 @@ class ReportComponent {
3472
3539
  // The stored default is part of the signature: saving a new default in report
3473
3540
  // settings rebuilds the config, but without a key change fs-filter keeps
3474
3541
  // rendering the values it was created with until a full page reload.
3542
+ groupBySignature(this.report),
3475
3543
  ...this._reportGroups().map((group) => `${group.id}:${group.level}:${group.label}:${defaultSignature(group)}`),
3476
- this._hasTimeSeriesChart() ? 'frequency' : '',
3477
3544
  ].join('|');
3478
3545
  }
3479
- // (Re)build the toolbar fs-filter config — report-level filter items, plus the
3480
- // runtime Frequency control when the report has a time-series chart. The
3481
- // report's actions live in the fs-menu beside the picker, not here. Only
3482
- // built/used once a report is loaded (the template gates it on `report`).
3546
+ // (Re)build the toolbar fs-filter config — the report-level filter items and
3547
+ // then the group-by control. The report's actions live in the fs-menu beside
3548
+ // the picker, not here. Only built/used once a report is loaded (the template
3549
+ // gates it on `report`).
3483
3550
  _buildReportConfig() {
3484
3551
  const reportId = this.report?.id ?? null;
3552
+ // Drop a grain the control no longer offers — the agent can narrow the list
3553
+ // mid-session, and an override the viewer can't see is worse than none.
3554
+ if (this.report && !isGranularityOffered(this.report, this._filterState.frequency())) {
3555
+ this._filterState.setFrequency(null);
3556
+ }
3485
3557
  const items = reportId
3486
3558
  ? this._reportGroups().map((group) => filterItemForGroup(group, this._reportData, reportId, this._filterState.value(group.id)))
3487
3559
  : [];
3488
- if (reportId && this._hasTimeSeriesChart()) {
3489
- items.push({
3490
- name: FREQUENCY_ITEM$1,
3491
- type: ItemType.Select,
3492
- label: 'Frequency',
3493
- multiple: false,
3494
- values: () => FREQUENCY_OPTIONS,
3495
- default: this._filterState.frequency() ?? undefined,
3496
- });
3560
+ // The group-by control leads the bar, ahead of every filter: it sets the
3561
+ // grain the whole report is read at, so it is the first thing a viewer
3562
+ // decides. It is not a filter group — it re-buckets rows rather than
3563
+ // narrowing them — so it is placed here rather than ordered among them.
3564
+ const groupByItem = this._groupByItem();
3565
+ if (groupByItem) {
3566
+ items.unshift(groupByItem);
3497
3567
  }
3498
3568
  this.reportFilterConfig = {
3499
3569
  // Never touch the URL or persist filter state — report filters are
@@ -3505,15 +3575,18 @@ class ReportComponent {
3505
3575
  for (const { groupId, value } of groupValuesFromQuery(query ?? {}, this._reportGroups())) {
3506
3576
  this._filterState.setValue(groupId, value);
3507
3577
  }
3508
- // Frequency isn't a filter group — it's a render-layer bucket override.
3509
- this._filterState.setFrequency(query?.[FREQUENCY_ITEM$1] || null);
3578
+ // Not a filter group — the grain is read straight off the query.
3579
+ this._filterState.setFrequency(query?.[GROUP_BY_ITEM] || null);
3510
3580
  },
3511
3581
  };
3512
3582
  }
3513
- // The report has at least one time-series chart (a chart whose x-axis is a
3514
- // time axis) the only kind the Frequency control affects.
3515
- _hasTimeSeriesChart() {
3516
- return (this.report?.pages ?? []).some((page) => page.components.some((component) => component.type === 'chart' && component.config?.xAxis?.kind === 'time'));
3583
+ // The report's group-by control as an fs-filter item, or null when the report
3584
+ // has none to show. Seeded from the session state so the grain the viewer
3585
+ // picked survives the report being re-fetched after a chat edit.
3586
+ _groupByItem() {
3587
+ return this.report
3588
+ ? groupByFilterItem(this.report, this._filterState.frequency())
3589
+ : null;
3517
3590
  }
3518
3591
  _refreshReportName(reportId) {
3519
3592
  this._reportService.load()
@@ -3539,7 +3612,7 @@ class ReportComponent {
3539
3612
  ReportPdfService,
3540
3613
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
3541
3614
  // this route's chunk.
3542
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CWXfRyqK.mjs').then((module) => module.default) }),
3615
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-6sMh877A.mjs').then((module) => module.default) }),
3543
3616
  ], viewQueries: [{ propertyName: "_split", first: true, predicate: ["split"], descendants: true, static: true }, { propertyName: "_chatPanel", first: true, predicate: ["chatPanel"], descendants: true, read: ElementRef, static: true }, { propertyName: "_canvas", first: true, predicate: ReportCanvasComponent, descendants: true }], ngImport: i0, template: "<div\n #split\n class=\"report fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n style=\"min-height: 500px;\"\n basePath=\"reports\"\n [requestData]=\"{ reportId: selected?.id ?? null }\"\n [introMessage]=\"introMessage\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n <div class=\"viewer fs-flex fs-column\">\n <div class=\"fs-row.align-center.gap-sm\">\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [fetch]=\"fetchReports\"\n [(ngModel)]=\"selected\"\n [disabled]=\"loadingReports\"\n [padless]=\"true\"\n [multiple]=\"false\"\n [fetchOnFocus]=\"true\"\n (ngModelChange)=\"reportChange($event)\"\n placeholder=\"Report\"\n name=\"report\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n <ng-template\n fsAutocompleteChipsStatic\n (click)=\"createReport()\">\n Create Report\n </ng-template>\n </fs-autocomplete-chips>\n @if (report) {\n <fs-menu>\n <ng-template\n fs-menu-item\n (click)=\"reportSettings()\">\n <mat-icon>\n tune\n </mat-icon>\n Report settings\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleEditMode()\"\n [disabled]=\"editMode\">\n <mat-icon>\n dashboard_2_edit\n </mat-icon>\n {{ editMode ? 'Done editing layout' : 'Edit layout' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n <mat-icon>\n slideshow\n </mat-icon>\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n <mat-icon>\n picture_as_pdf\n </mat-icon>\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data\n </ng-template>\n @if ((report?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n }\n </div>\n @if (report) {\n <!-- Report-level filters only (the report's actions live in the menu\n above). fs-filter reads its config once at init, so it's keyed on the\n filter signature: when the report-level filter set changes the block\n is recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters) {\n @for (key of [reportFilterKey]; track key) {\n <fs-filter [config]=\"reportFilterConfig\"></fs-filter>\n }\n }\n <app-report-canvas\n [report]=\"report\"\n [editMode]=\"editMode\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\"\n (editDone)=\"toggleEditMode()\">\n </app-report-canvas>\n }\n </div>\n</div>", styles: [".report{height:100%;min-height:500px}.report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:500px;min-width:0;border:none}.report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.report .viewer fs-filter{flex:0 0 auto;margin-top:10px;margin-bottom:0}.report .viewer ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.report .resizer:before{content:\"\";width:0px;background:#0000001f;transition:width .12s ease,background-color .12s ease}.report .resizer:hover:before{width:3px;background:var(--brand-primary-color)}.report.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.report.resizing .resizer:before{width:3px;background:var(--brand-primary-color)}.report.resizing .chat,.report.resizing .viewer{pointer-events:none}::ng-deep body.body-report-reports .mat-mdc-card-content{display:flex;flex-direction:column;box-sizing:border-box}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel{flex:1;min-height:0}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel router-outlet+ng-component{height:100%}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: FsAutocompleteChipsModule }, { kind: "component", type: i2$2.FsAutocompleteChipsComponent, selector: "fs-autocomplete-chips", inputs: ["fetch", "appearance", "floatLabel", "readonly", "size", "label", "placeholder", "chipImage", "chipBackground", "chipColor", "chipIcon", "chipIconColor", "chipClass", "chipPadding", "shape", "hint", "allowText", "allowObject", "delay", "minPanelWidth", "maxPanelHeight", "validateText", "removable", "allowClear", "color", "background", "orderable", "padless", "initOnClick", "fetchOnFocus", "multiple", "multipleAdd", "confirm", "disabled", "groupBy", "panelWidth", "panelClass", "compareWith"], outputs: ["selected", "removed", "reordered", "clear", "panelOpened", "panelClosed"] }, { kind: "directive", type: i2$2.FsAutocompleteObjectDirective, selector: "[fsAutocompleteObject],[fsAutocompleteChipsTemplate]" }, { kind: "directive", type: i2$2.FsAutocompleteChipsStaticDirective, selector: "[fsAutocompleteChipsStatic]", inputs: ["show", "disable"], outputs: ["click", "selected"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2$3.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$3.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: ReportCanvasComponent, selector: "app-report-canvas", inputs: ["report", "editMode"], outputs: ["componentSettings", "reportChanged", "editDone"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3544
3617
  }
3545
3618
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponent, decorators: [{
@@ -3553,7 +3626,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
3553
3626
  ReportPdfService,
3554
3627
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
3555
3628
  // this route's chunk.
3556
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CWXfRyqK.mjs').then((module) => module.default) }),
3629
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-6sMh877A.mjs').then((module) => module.default) }),
3557
3630
  ], imports: [
3558
3631
  FormsModule,
3559
3632
  FsAutocompleteChipsModule,
@@ -4602,9 +4675,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
4602
4675
  ], template: "<fs-filter [config]=\"filterConfig\"></fs-filter>\n\n<ng-container *fsSkeleton=\"nodes()\">\n <fs-tree [config]=\"treeConfig\">\n <ng-template\n fsTreeNode\n let-data=\"data\">\n <div class=\"fs-row.gap-sm.align-center node-row\">\n <mat-icon class=\"node-icon\">\n {{ data.type === nodeType.Folder ? 'folder' : 'insert_chart_outlined' }}\n </mat-icon>\n\n @if (data.type === nodeType.Folder) {\n <span class=\"node-name\">{{ data.name }}</span>\n } @else if (isDeleted(data.report)) {\n <!-- A deleted report can't be opened (the backend only assembles\n active ones), so in Show Deleted mode the name is plain text \u2014\n restore it from the row menu first. -->\n <span class=\"node-name\">{{ data.name }}</span>\n } @else {\n <a\n class=\"node-name\"\n [routerLink]=\"openLink(data.report)\">\n {{ data.name }}\n </a>\n }\n </div>\n </ng-template>\n </fs-tree>\n\n @if (!nodes().length) {\n <p class=\"small tree-empty\">\n No reports yet. Create one, or add a folder to group them.\n </p>\n }\n</ng-container>\n", styles: ["a{cursor:pointer}fs-tree .node>.container{flex:1;min-width:0}fs-tree .node>.container>.content{min-width:0}fs-tree .node>.container>button{margin-top:-5px}.node-row{min-width:0}.node-icon{font-size:18px;width:18px;height:18px;flex:none}.node-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-empty{margin-top:10px}\n"] }]
4603
4676
  }], ctorParameters: () => [] });
4604
4677
 
4605
- // fs-filter query key for the runtime Frequency control (a render-layer bucket
4606
- // override, not a filter group — so it's read straight off the query).
4607
- const FREQUENCY_ITEM = 'frequency';
4608
4678
  // A single report: the AI chat on the left, the report canvas on the right,
4609
4679
  // under an H2 title + settings gear. The host supplies [reportId] (from its
4610
4680
  // route) and switches reports by navigating — this component never picks one.
@@ -4810,10 +4880,10 @@ class FsAiReportComponent {
4810
4880
  .filter((group) => group.level === 'report' || group.level === 'both')
4811
4881
  .sort((a, b) => a.order - b.order);
4812
4882
  }
4813
- // Whether the loaded report has any report-level filters OR a time-series
4814
- // chart (which earns the runtime Frequency control) gates the toolbar.
4883
+ // Gates the toolbar fs-filter so it isn't rendered when there is nothing to
4884
+ // put in it no report-level filters and no group-by control.
4815
4885
  get reportHasFilters() {
4816
- return this._reportGroups().length > 0 || this._hasTimeSeriesChart();
4886
+ return this._reportGroups().length > 0 || !!this._groupByItem();
4817
4887
  }
4818
4888
  // fs-filter reads its config only once (at init). The template keys the
4819
4889
  // toolbar fs-filter on this signature so it's recreated (and re-reads the
@@ -4823,26 +4893,29 @@ class FsAiReportComponent {
4823
4893
  // The stored default is part of the signature: saving a new default in report
4824
4894
  // settings rebuilds the config, but without a key change fs-filter keeps
4825
4895
  // rendering the values it was created with until a full page reload.
4896
+ groupBySignature(this.report),
4826
4897
  ...this._reportGroups().map((group) => `${group.id}:${group.level}:${group.label}:${defaultSignature(group)}`),
4827
- this._hasTimeSeriesChart() ? 'frequency' : '',
4828
4898
  ].join('|');
4829
4899
  }
4830
- // (Re)build the toolbar fs-filter config — report-level filter items, plus
4831
- // the runtime Frequency control when the report has a time-series chart.
4900
+ // (Re)build the toolbar fs-filter config — the report-level filter items and
4901
+ // then the group-by control.
4832
4902
  _buildReportConfig() {
4833
4903
  const reportId = this.report?.id ?? null;
4904
+ // Drop a grain the control no longer offers — the agent can narrow the list
4905
+ // mid-session, and an override the viewer can't see is worse than none.
4906
+ if (this.report && !isGranularityOffered(this.report, this._filterState.frequency())) {
4907
+ this._filterState.setFrequency(null);
4908
+ }
4834
4909
  const items = reportId
4835
4910
  ? this._reportGroups().map((group) => filterItemForGroup(group, this._reportData, reportId, this._filterState.value(group.id)))
4836
4911
  : [];
4837
- if (reportId && this._hasTimeSeriesChart()) {
4838
- items.push({
4839
- name: FREQUENCY_ITEM,
4840
- type: ItemType.Select,
4841
- label: 'Frequency',
4842
- multiple: false,
4843
- values: () => FREQUENCY_OPTIONS,
4844
- default: this._filterState.frequency() ?? undefined,
4845
- });
4912
+ // The group-by control leads the bar, ahead of every filter: it sets the
4913
+ // grain the whole report is read at, so it is the first thing a viewer
4914
+ // decides. It is not a filter group — it re-buckets rows rather than
4915
+ // narrowing them — so it is placed here rather than ordered among them.
4916
+ const groupByItem = this._groupByItem();
4917
+ if (groupByItem) {
4918
+ items.unshift(groupByItem);
4846
4919
  }
4847
4920
  this.reportFilterConfig = {
4848
4921
  // Never touch the URL or persist filter state — report filters are
@@ -4854,15 +4927,18 @@ class FsAiReportComponent {
4854
4927
  for (const { groupId, value } of groupValuesFromQuery(query ?? {}, this._reportGroups())) {
4855
4928
  this._filterState.setValue(groupId, value);
4856
4929
  }
4857
- // Frequency isn't a filter group — it's a render-layer bucket override.
4858
- this._filterState.setFrequency(query?.[FREQUENCY_ITEM] || null);
4930
+ // Not a filter group — the grain is read straight off the query.
4931
+ this._filterState.setFrequency(query?.[GROUP_BY_ITEM] || null);
4859
4932
  },
4860
4933
  };
4861
4934
  }
4862
- // The report has at least one time-series chart (x-axis is a time axis) — the
4863
- // only kind the Frequency control affects.
4864
- _hasTimeSeriesChart() {
4865
- return (this.report?.pages ?? []).some((page) => page.components.some((component) => component.type === 'chart' && component.config?.xAxis?.kind === 'time'));
4935
+ // The report's group-by control as an fs-filter item, or null when the report
4936
+ // has none to show. Seeded from the session state so the grain the viewer
4937
+ // picked survives the report being re-fetched after a chat edit.
4938
+ _groupByItem() {
4939
+ return this.report
4940
+ ? groupByFilterItem(this.report, this._filterState.frequency())
4941
+ : null;
4866
4942
  }
4867
4943
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FsAiReportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4868
4944
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: FsAiReportComponent, isStandalone: true, selector: "fs-ai-report", inputs: { reportId: { classPropertyName: "reportId", publicName: "reportId", isSignal: true, isRequired: false, transformFunction: null }, basePath: { classPropertyName: "basePath", publicName: "basePath", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { deleted: "deleted", renamed: "renamed" }, providers: [
@@ -4873,7 +4949,7 @@ class FsAiReportComponent {
4873
4949
  ReportPptxService,
4874
4950
  ReportPdfService,
4875
4951
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
4876
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CWXfRyqK.mjs').then((module) => module.default) }),
4952
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-6sMh877A.mjs').then((module) => module.default) }),
4877
4953
  ], viewQueries: [{ propertyName: "_split", first: true, predicate: ["split"], descendants: true, static: true }, { propertyName: "_chatPanel", first: true, predicate: ["chatPanel"], descendants: true, read: ElementRef, static: true }, { propertyName: "_canvas", first: true, predicate: ReportCanvasComponent, descendants: true }], ngImport: i0, template: "<div class=\"fs-ai-report fs-column\">\n\n <div class=\"fs-ai-report__header fs-row.align-center\">\n <h2 class=\"fs-ai-report__title fs-flex\">{{ report?.name }}</h2>\n\n @if (report) {\n <!-- Report-level filters, inline with the toolbar actions. fs-filter\n reads its config once at init, so it's keyed on the filter\n signature: when the report-level filter set changes the block is\n recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters) {\n @for (key of [reportFilterKey]; track key) {\n <fs-filter\n class=\"fs-ai-report__filters\"\n [config]=\"reportFilterConfig\">\n </fs-filter>\n }\n }\n\n <button\n mat-icon-button\n type=\"button\"\n [matTooltip]=\"editMode ? 'Done' : 'Edit layout'\"\n (click)=\"toggleEditMode()\">\n <mat-icon>{{ editMode ? 'lock' : 'open_with' }}</mat-icon>\n </button>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Export\"\n [fsMenuTriggerFor]=\"exportMenu\">\n <mat-icon>download</mat-icon>\n </button>\n <fs-menu #exportMenu>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n Export Raw Data\n </ng-template>\n @if ((report?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Report settings\"\n (click)=\"reportSettings()\">\n <mat-icon>settings</mat-icon>\n </button>\n }\n </div>\n\n <div\n #split\n class=\"fs-ai-report__split fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n [basePath]=\"basePath()\"\n [requestData]=\"{ reportId: reportId() }\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n\n <div class=\"viewer fs-flex fs-column\">\n @if (report) {\n <app-report-canvas\n [report]=\"report\"\n [editMode]=\"editMode\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\"\n (editDone)=\"toggleEditMode()\">\n </app-report-canvas>\n }\n </div>\n </div>\n</div>\n", styles: [".fs-ai-report{height:100%;min-height:500px;display:flex;flex-direction:column}.fs-ai-report__header{flex:0 0 auto;margin-bottom:10px}.fs-ai-report__title{margin:0;min-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fs-ai-report__filters{flex:0 1 auto;min-width:0;display:block;margin:0 5px 0 0}.fs-ai-report__filters ::ng-deep .filter-bar-container{align-items:center!important}.fs-ai-report__filters ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.fs-ai-report__split{flex:1 1 auto;min-height:0}.fs-ai-report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:400px;min-width:0;border:none}.fs-ai-report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.fs-ai-report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.fs-ai-report .resizer:before{content:\"\";width:3px;border-radius:3px;background:transparent;transition:background-color .12s ease}.fs-ai-report .resizer:hover:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.fs-ai-report__split.resizing .resizer:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing .chat,.fs-ai-report__split.resizing .viewer{pointer-events:none}\n"], dependencies: [{ kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2$3.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$3.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2$3.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3$2.MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i4$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2$5.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: ReportCanvasComponent, selector: "app-report-canvas", inputs: ["report", "editMode"], outputs: ["componentSettings", "reportChanged", "editDone"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4878
4954
  }
4879
4955
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FsAiReportComponent, decorators: [{
@@ -4886,7 +4962,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
4886
4962
  ReportPptxService,
4887
4963
  ReportPdfService,
4888
4964
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
4889
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CWXfRyqK.mjs').then((module) => module.default) }),
4965
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-6sMh877A.mjs').then((module) => module.default) }),
4890
4966
  ], imports: [
4891
4967
  FsFilterModule,
4892
4968
  FsMenuModule,
@@ -4919,5 +4995,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
4919
4995
  * Generated bundle index. Do not edit.
4920
4996
  */
4921
4997
 
4922
- export { FsAiReportsComponent as F, REPORT_CHART_COLORS_CSS as R, ReportComponent as a, FsAiReportComponent as b, ReportData as c, ReportService as d, ReportFilterStateService as e, FREQUENCY_OPTIONS as f, ReportNodeType as g, ReportState as h, ReportStates as i };
4923
- //# sourceMappingURL=firestitch-report-firestitch-report-BCb2lTJC.mjs.map
4998
+ export { FsAiReportsComponent as F, GRANULARITY_OPTIONS as G, REPORT_CHART_COLORS_CSS as R, ReportComponent as a, FsAiReportComponent as b, ReportData as c, ReportService as d, ReportFilterStateService as e, ReportNodeType as f, ReportState as g, ReportStates as h };
4999
+ //# sourceMappingURL=firestitch-report-firestitch-report-B8LjVTvx.mjs.map