@firestitch/report 18.0.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 (58) hide show
  1. package/app/reports/components/component-chart/component-chart.component.d.ts +16 -0
  2. package/app/reports/components/component-kpi/component-kpi.component.d.ts +12 -0
  3. package/app/reports/components/component-list/component-list.component.d.ts +27 -0
  4. package/app/reports/components/report-canvas/report-canvas.component.d.ts +84 -0
  5. package/app/reports/components/report-component/report-component.component.d.ts +71 -0
  6. package/app/reports/components/timezone-select/timezone-select.component.d.ts +18 -0
  7. package/app/reports/data/report.data.d.ts +48 -0
  8. package/app/reports/dialogs/component-settings/component-settings.component.d.ts +49 -0
  9. package/app/reports/dialogs/report-settings/report-settings.component.d.ts +25 -0
  10. package/app/reports/export/offscreen-chart.d.ts +2 -0
  11. package/app/reports/export/report-export-collector.service.d.ts +26 -0
  12. package/app/reports/export/report-pdf.service.d.ts +13 -0
  13. package/app/reports/export/report-pptx.service.d.ts +14 -0
  14. package/app/reports/format.d.ts +1 -0
  15. package/app/reports/interfaces/report.interface.d.ts +174 -0
  16. package/app/reports/layout.d.ts +11 -0
  17. package/app/reports/option-builder.d.ts +15 -0
  18. package/app/reports/report-filter-items.d.ts +9 -0
  19. package/app/reports/services/report-filter-state.service.d.ts +25 -0
  20. package/app/reports/services/report.service.d.ts +15 -0
  21. package/app/reports/theme/echarts.d.ts +2 -0
  22. package/app/reports/theme/palette.d.ts +2 -0
  23. package/app/reports/views/report/report.component.d.ts +50 -0
  24. package/esm2022/app/reports/components/component-chart/component-chart.component.mjs +47 -0
  25. package/esm2022/app/reports/components/component-kpi/component-kpi.component.mjs +33 -0
  26. package/esm2022/app/reports/components/component-list/component-list.component.mjs +178 -0
  27. package/esm2022/app/reports/components/report-canvas/report-canvas.component.mjs +347 -0
  28. package/esm2022/app/reports/components/report-component/report-component.component.mjs +453 -0
  29. package/esm2022/app/reports/components/timezone-select/timezone-select.component.mjs +70 -0
  30. package/esm2022/app/reports/data/report.data.mjs +152 -0
  31. package/esm2022/app/reports/dialogs/component-settings/component-settings.component.mjs +221 -0
  32. package/esm2022/app/reports/dialogs/report-settings/report-settings.component.mjs +109 -0
  33. package/esm2022/app/reports/export/offscreen-chart.mjs +33 -0
  34. package/esm2022/app/reports/export/report-export-collector.service.mjs +94 -0
  35. package/esm2022/app/reports/export/report-pdf.service.mjs +155 -0
  36. package/esm2022/app/reports/export/report-pptx.service.mjs +224 -0
  37. package/esm2022/app/reports/format.mjs +25 -0
  38. package/esm2022/app/reports/interfaces/report.interface.mjs +16 -0
  39. package/esm2022/app/reports/layout.mjs +50 -0
  40. package/esm2022/app/reports/option-builder.mjs +293 -0
  41. package/esm2022/app/reports/report-filter-items.mjs +125 -0
  42. package/esm2022/app/reports/services/report-filter-state.service.mjs +177 -0
  43. package/esm2022/app/reports/services/report.service.mjs +56 -0
  44. package/esm2022/app/reports/theme/echarts.mjs +59 -0
  45. package/esm2022/app/reports/theme/palette.mjs +10 -0
  46. package/esm2022/app/reports/views/report/report.component.mjs +354 -0
  47. package/esm2022/firestitch-report.mjs +5 -0
  48. package/esm2022/public_api.mjs +13 -0
  49. package/fesm2022/firestitch-report-echarts-BxYnpz7n.mjs +60 -0
  50. package/fesm2022/firestitch-report-echarts-BxYnpz7n.mjs.map +1 -0
  51. package/fesm2022/firestitch-report-firestitch-report-Cnotycly.mjs +3107 -0
  52. package/fesm2022/firestitch-report-firestitch-report-Cnotycly.mjs.map +1 -0
  53. package/fesm2022/firestitch-report.mjs +2 -0
  54. package/fesm2022/firestitch-report.mjs.map +1 -0
  55. package/index.d.ts +5 -0
  56. package/package.json +39 -0
  57. package/public_api.d.ts +5 -0
  58. package/styles.scss +1 -0
@@ -0,0 +1,3107 @@
1
+ import * as i0 from '@angular/core';
2
+ import { inject, Injectable, Component, ChangeDetectionStrategy, Input, DestroyRef, ViewChild, EventEmitter, ChangeDetectorRef, NgZone, ElementRef, Output, HostBinding, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
3
+ import * as i1$3 from '@angular/forms';
4
+ import { FormsModule, ControlContainer, NgForm } from '@angular/forms';
5
+ import { MAT_DIALOG_DATA, MatDialogRef, MatDialogTitle, MatDialogContent, MatDialogActions, MatDialog } from '@angular/material/dialog';
6
+ import { MatIcon } from '@angular/material/icon';
7
+ import * as i2$2 from '@firestitch/autocomplete-chips';
8
+ import { FsAutocompleteChipsModule } from '@firestitch/autocomplete-chips';
9
+ import * as i2 from '@firestitch/filter';
10
+ import { ItemType, FsFilterModule } from '@firestitch/filter';
11
+ import * as i4$1 from '@firestitch/menu';
12
+ import { FsMenuModule } from '@firestitch/menu';
13
+ import { FsProcess } from '@firestitch/process';
14
+ import { FsPrompt } from '@firestitch/prompt';
15
+ import { of, Subject, map as map$1, debounceTime, forkJoin, BehaviorSubject, from } from 'rxjs';
16
+ import { map, shareReplay, filter, tap, switchMap, catchError, finalize } from 'rxjs/operators';
17
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
18
+ import { NgxEchartsDirective, provideEchartsCore } from 'ngx-echarts';
19
+ import { FsAiChatComponent } from '@firestitch/ai';
20
+ import { MatIconButton, MatButton } from '@angular/material/button';
21
+ import { MatTooltip } from '@angular/material/tooltip';
22
+ import * as i1$2 from '@firestitch/zoom-pan';
23
+ import { FsZoomPanComponent, FsZoomPanModule } from '@firestitch/zoom-pan';
24
+ import { FsApi } from '@firestitch/api';
25
+ import * as i1$1 from '@angular/material/menu';
26
+ import { MatMenuModule } from '@angular/material/menu';
27
+ import { format, startOfYear, startOfQuarter, startOfMonth, subMonths, subDays, parseISO, isValid } from 'date-fns';
28
+ import { DatePipe } from '@angular/common';
29
+ import * as i1 from '@firestitch/list';
30
+ import { PaginationStrategy, FsListComponent, FsListModule } from '@firestitch/list';
31
+ import { MatFormField, MatLabel, MatSuffix, MatHint } from '@angular/material/form-field';
32
+ import { MatInput } from '@angular/material/input';
33
+ import { MatSlideToggle } from '@angular/material/slide-toggle';
34
+ import * as i5 from '@angular/material/tabs';
35
+ import { MatTabsModule } from '@angular/material/tabs';
36
+ import * as i3 from '@firestitch/dialog';
37
+ import { FsDialogModule } from '@firestitch/dialog';
38
+ import * as i2$1 from '@firestitch/form';
39
+ import { FsFormModule } from '@firestitch/form';
40
+ import { FsLabelModule } from '@firestitch/label';
41
+ import { FsMessage } from '@firestitch/message';
42
+ import * as i4 from '@firestitch/tabs';
43
+ import { FsTabsModule } from '@firestitch/tabs';
44
+ import { MatSelect, MatOption as MatOption$1 } from '@angular/material/select';
45
+ import { MatOption } from '@angular/material/core';
46
+ import { guid } from '@firestitch/common';
47
+
48
+ // API gateway for the Reports module. Every endpoint hangs off [basePath],
49
+ // which defaults to "reports" (the framework reports backend at /api/reports)
50
+ // and can be repointed by the host so the package stays self-contained and portable.
51
+ class ReportData {
52
+ _api = inject(FsApi);
53
+ // The API root for every reports endpoint. Defaults to the framework reports
54
+ // context; set it once (e.g. from the host's report page) to mount elsewhere.
55
+ basePath = 'reports';
56
+ // Build an endpoint path under the configured base, e.g. _path() => "ai/reports",
57
+ // _path(`${id}/pages`) => "ai/reports/123/pages".
58
+ _path(suffix = '') {
59
+ return suffix ? `${this.basePath}/${suffix}` : this.basePath;
60
+ }
61
+ reports(query = {}, config = {}) {
62
+ return this._api.get(this._path(), query, {
63
+ key: 'reports',
64
+ ...config,
65
+ });
66
+ }
67
+ create(name, config = {}) {
68
+ return this._api.post(this._path(), { name }, {
69
+ key: 'report',
70
+ ...config,
71
+ });
72
+ }
73
+ rename(reportId, name, config = {}) {
74
+ return this._api.put(this._path(`${reportId}`), { name }, {
75
+ key: 'report',
76
+ ...config,
77
+ });
78
+ }
79
+ // Report settings: any subset of { name, pageSize, pageOrientation, layout,
80
+ // styles } — styles carry the report's typographic config (e.g. heading size).
81
+ update(reportId, settings, config = {}) {
82
+ return this._api.put(this._path(`${reportId}`), settings, {
83
+ key: 'report',
84
+ ...config,
85
+ });
86
+ }
87
+ // Append a blank page to the report; resolves to the new page.
88
+ addPage(reportId, config = {}) {
89
+ return this._api.post(this._path(`${reportId}/pages`), {}, {
90
+ key: 'page',
91
+ ...config,
92
+ });
93
+ }
94
+ // Component settings: any subset of { title, padding } — the human-facing
95
+ // dialog edits; geometry (x/y/w/h/flowWidth) is persisted via savePositions
96
+ // from the canvas instead.
97
+ updateComponent(reportId, componentId, settings, config = {}) {
98
+ return this._api.put(this._path(`${reportId}/components/${componentId}/settings`), settings, {
99
+ key: 'component',
100
+ ...config,
101
+ });
102
+ }
103
+ delete(reportId, config = {}) {
104
+ return this._api.delete(this._path(`${reportId}`), {}, {
105
+ key: 'report',
106
+ ...config,
107
+ });
108
+ }
109
+ // The assembled structure: pages → components (+ their filters) and the
110
+ // report's filter groups.
111
+ get(reportId, config = {}) {
112
+ return this._api.get(this._path(`${reportId}`), {}, {
113
+ key: 'report',
114
+ ...config,
115
+ });
116
+ }
117
+ // Persist geometry for the moved components only — any subset of
118
+ // { x, y, w, h } (inches), flowWidth (percent) and order (flow position).
119
+ savePositions(reportId, positions, config = {}) {
120
+ return this._api.put(this._path(`${reportId}/components/positions`), { positions }, {
121
+ key: null,
122
+ ...config,
123
+ });
124
+ }
125
+ // One component's data. The body carries everything session-scoped: the
126
+ // resolved filter values and the component's own interaction state.
127
+ componentData(reportId, componentId, filters = [], state = {}, config = {}) {
128
+ return this._api.post(this._path(`${reportId}/components/${componentId}/data`), { filters, state }, {
129
+ key: 'data',
130
+ ...config,
131
+ });
132
+ }
133
+ // Exports the component's FULL filtered dataset to CSV server-side and
134
+ // resolves to a presigned download URL. The backend streams percentage
135
+ // progress while chunking; consume through FsProcess.download.
136
+ exportComponent(reportId, componentId, filters = [], state = {}, config = {}) {
137
+ return this._api.post(this._path(`${reportId}/components/${componentId}/export`), { filters, state }, {
138
+ key: 'url',
139
+ ...config,
140
+ });
141
+ }
142
+ // Distinct options for a select filter (drives multiselect controls).
143
+ filterOptions(reportId, filterId, config = {}) {
144
+ return this._api.get(this._path(`${reportId}/filters/${filterId}/options`), {}, {
145
+ key: 'options',
146
+ ...config,
147
+ });
148
+ }
149
+ // The component SQL's filterable output columns — drives the column picker
150
+ // in the filter UI so a column name is never typed by hand.
151
+ componentColumns(reportId, componentId, config = {}) {
152
+ return this._api.get(this._path(`${reportId}/components/${componentId}/columns`), {}, {
153
+ key: 'columns',
154
+ ...config,
155
+ });
156
+ }
157
+ // Make one column filterable. `level` (component | report) places a new
158
+ // group; `groupId` instead links into an existing group. A select filter's
159
+ // options are derived from the component SQL when optionsSql is omitted.
160
+ addFilter(reportId, componentId, filter, config = {}) {
161
+ return this._api.post(this._path(`${reportId}/components/${componentId}/filters`), filter, {
162
+ key: 'filter',
163
+ ...config,
164
+ });
165
+ }
166
+ // Update a filter — any subset of { label, optionsSql, enabled }.
167
+ updateFilter(reportId, filterId, changes, config = {}) {
168
+ return this._api.put(this._path(`${reportId}/filters/${filterId}`), changes, {
169
+ key: 'filter',
170
+ ...config,
171
+ });
172
+ }
173
+ // Remove a filter (its group goes too when nothing else uses it).
174
+ deleteFilter(reportId, filterId, config = {}) {
175
+ return this._api.delete(this._path(`${reportId}/filters/${filterId}`), {}, {
176
+ key: 'filter',
177
+ ...config,
178
+ });
179
+ }
180
+ // Set where a filter group renders: 'component', 'report' or 'both'.
181
+ setFilterGroupLevel(reportId, groupId, level, config = {}) {
182
+ return this._api.put(this._path(`${reportId}/filtergroups/${groupId}`), { level }, {
183
+ key: 'filterGroup',
184
+ ...config,
185
+ });
186
+ }
187
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportData, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
188
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportData, providedIn: 'root' });
189
+ }
190
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportData, decorators: [{
191
+ type: Injectable,
192
+ args: [{
193
+ providedIn: 'root',
194
+ }]
195
+ }] });
196
+
197
+ // Page padding for flow layout, inches — mirrored by the canvas CSS.
198
+ const FLOW_PADDING = 0.25;
199
+ // Default content padding around a component's body, inches (1/8").
200
+ const DEFAULT_COMPONENT_PADDING = 0.125;
201
+ // Default report heading size, points (1/72"). Applies to component titles.
202
+ const DEFAULT_HEADING_SIZE = 16;
203
+ // Resolved geometry (inches) for every component on a page, in BOTH layout
204
+ // modes — the single definition the PPT and PDF exports lay out from:
205
+ //
206
+ // Freeform: the stored x/y/w/h verbatim.
207
+ // Flow: components in `order`, each flowWidth% of the content width,
208
+ // wrapping into rows (a row breaks when the next component would
209
+ // exceed 100%); row height = the tallest component in the row.
210
+ // Matches the canvas's flex-wrap rendering.
211
+ function pageGeometry(report, page) {
212
+ const geometry = new Map();
213
+ if (report.layout !== 'flow') {
214
+ for (const component of page.components) {
215
+ geometry.set(component.id, {
216
+ x: component.x, y: component.y, w: component.w, h: component.h,
217
+ });
218
+ }
219
+ return geometry;
220
+ }
221
+ const contentW = report.pageWidth - FLOW_PADDING * 2;
222
+ const sorted = [...page.components].sort((a, b) => (a.order - b.order) || (a.y - b.y) || (a.x - b.x));
223
+ let cursorX = 0; // fraction of contentW consumed in the current row
224
+ let rowTop = FLOW_PADDING;
225
+ let rowHeight = 0;
226
+ for (const component of sorted) {
227
+ const fraction = Math.min(100, Math.max(10, component.flowWidth ?? 50)) / 100;
228
+ // Wrap when this component doesn't fit the remainder (small tolerance for
229
+ // 33.33+33.33+33.33 style splits).
230
+ if (cursorX + fraction > 1.001) {
231
+ rowTop += rowHeight;
232
+ cursorX = 0;
233
+ rowHeight = 0;
234
+ }
235
+ geometry.set(component.id, {
236
+ x: FLOW_PADDING + cursorX * contentW,
237
+ y: rowTop,
238
+ w: fraction * contentW,
239
+ h: component.h,
240
+ });
241
+ cursorX += fraction;
242
+ rowHeight = Math.max(rowHeight, component.h);
243
+ }
244
+ return geometry;
245
+ }
246
+
247
+ // A date-range boundary as the picked LOCAL calendar day (no time, no tz).
248
+ // Date-range filters are calendar-day semantics, so the boundary must carry
249
+ // only Y-M-D — never a timezone-bearing instant the backend would shift.
250
+ function dateBoundString(value) {
251
+ if (value instanceof Date) {
252
+ return format(value, 'yyyy-MM-dd');
253
+ }
254
+ // Authored/relative defaults arrive as strings already in Y-M-D form;
255
+ // keep just the date portion.
256
+ return value ? String(value).slice(0, 10) : undefined;
257
+ }
258
+ // Maps a report filter GROUP to an fs-filter config item, and reads fs-filter
259
+ // query values back into per-group FilterGroupValues. Shared by the report-bar
260
+ // fs-filter and the per-component fs-filter so both render through the same
261
+ // library and write into ReportFilterStateService identically. Items are named
262
+ // `g<groupId>` so the query keys map straight back to the group.
263
+ //
264
+ // (Lists keep their own filters in FsList and name items `f<filterId>`; this is
265
+ // the group-keyed path for charts/KPIs and the report bar.)
266
+ // The query key fs-filter uses for a group's item(s).
267
+ const itemName = (group) => `g${group.id}`;
268
+ // Build the fs-filter config item for a group, seeding its default from the
269
+ // current session value so authored/relative defaults show selected on open.
270
+ function filterItemForGroup(group, reportData, reportId, initial) {
271
+ const name = itemName(group);
272
+ const label = group.label || group.filters?.[0]?.filterColumn || '';
273
+ switch (group.type) {
274
+ case 'dateRange':
275
+ return {
276
+ name,
277
+ type: ItemType.DateRange,
278
+ label: { from: `${label} From`, to: `${label} To` },
279
+ default: (initial?.start || initial?.end)
280
+ ? { from: initial.start ?? undefined, to: initial.end ?? undefined }
281
+ : undefined,
282
+ };
283
+ case 'select': {
284
+ // Options come from the first member filter that declares them — members
285
+ // are the same data point, so any member's list serves the group.
286
+ const optionFilter = (group.filters ?? []).find((member) => member.hasOptions);
287
+ // The distinct option list is fetched once (server caps it) and replayed
288
+ // to every keystroke, so typing never refetches.
289
+ let options$ = null;
290
+ const loadOptions = () => {
291
+ if (!optionFilter) {
292
+ return of([]);
293
+ }
294
+ if (!options$) {
295
+ options$ = reportData.filterOptions(reportId, optionFilter.id)
296
+ .pipe(map((options) => (options ?? [])
297
+ .map((option) => ({ name: String(option), value: option }))), shareReplay({ bufferSize: 1, refCount: false }));
298
+ }
299
+ return options$;
300
+ };
301
+ return {
302
+ name,
303
+ type: ItemType.AutoCompleteChips,
304
+ label,
305
+ fetchOnFocus: true,
306
+ // fs-autocomplete renders whatever we return verbatim, so the typed
307
+ // keyword has to be matched here. Match case-insensitively against the
308
+ // displayed name — works for any select filter regardless of its column.
309
+ values: (keyword) => loadOptions()
310
+ .pipe(map((options) => {
311
+ const term = (keyword ?? '').trim().toLowerCase();
312
+ return term
313
+ ? options.filter((option) => option.name.toLowerCase().includes(term))
314
+ : options;
315
+ })),
316
+ default: initial?.values?.length
317
+ ? initial.values.map((value) => ({ name: String(value), value }))
318
+ : undefined,
319
+ };
320
+ }
321
+ case 'keyword':
322
+ default:
323
+ return {
324
+ name,
325
+ type: ItemType.Keyword,
326
+ label,
327
+ default: initial?.value,
328
+ };
329
+ }
330
+ }
331
+ // Read an fs-filter change query into per-group values. Returns one entry per
332
+ // group with the value to store (or null to clear) — callers feed these to
333
+ // ReportFilterStateService.setValue.
334
+ function groupValuesFromQuery(query, groups) {
335
+ return groups.map((group) => {
336
+ const name = itemName(group);
337
+ if (group.type === 'dateRange') {
338
+ const start = query[`${name}From`];
339
+ const end = query[`${name}To`];
340
+ return {
341
+ groupId: group.id,
342
+ value: (start || end) ? { start: start || null, end: end || null } : null,
343
+ };
344
+ }
345
+ if (group.type === 'select') {
346
+ const values = selectQueryValues(query[name]);
347
+ return { groupId: group.id, value: values.length ? { values } : null };
348
+ }
349
+ const keyword = String(query[name] ?? '').trim();
350
+ return { groupId: group.id, value: keyword ? { value: keyword } : null };
351
+ });
352
+ }
353
+ // fs-filter's AutoCompleteChips serialises a multi-select into ONE query value:
354
+ // the selected values joined by "," (its `query` getter does `.map(value).join(',')`).
355
+ // So a 2+ selection arrives as the string "a,b", not an array — splitting on ","
356
+ // reverses that faithfully. Without this, a multi-select was sent as the single
357
+ // value ["a,b"], producing `col IN ('a,b')` server-side, which matches nothing.
358
+ function selectQueryValues(raw) {
359
+ if (Array.isArray(raw)) {
360
+ return raw;
361
+ }
362
+ if (raw === null || raw === undefined || raw === '') {
363
+ return [];
364
+ }
365
+ return String(raw).split(',');
366
+ }
367
+
368
+ // Session filter state for the open report. Filter VALUES are never persisted
369
+ // — this service holds them per filter GROUP (the propagation unit), resolves
370
+ // them into per-component member-filter values for each data request, and
371
+ // notifies the components whose groups changed so only they refetch.
372
+ //
373
+ // Also session-scoped: per-filter enable/disable toggles from a component's
374
+ // filter menu (the `enabled` column is the authored default; a viewer's toggle
375
+ // shouldn't write to the report).
376
+ class ReportFilterStateService {
377
+ _values = new Map();
378
+ _groups = new Map();
379
+ _sessionDisabled = new Set();
380
+ // The runtime Frequency control: a single session-wide bucket override for all
381
+ // time-series charts. Unset = each chart uses its authored granularity. Never
382
+ // persisted (it dies with this report view), and carried in the data request's
383
+ // `state`, so it propagates through to the PDF/PowerPoint exports too.
384
+ _frequency = null;
385
+ _frequencyChanged$ = new Subject();
386
+ // Emits the group id that changed; components listen filtered to their own
387
+ // group ids. null = everything changed (re-init).
388
+ _changed$ = new Subject();
389
+ // Seed groups + their defaults when a report loads. Existing session values
390
+ // for still-existing groups survive a structure re-fetch (a chat edit
391
+ // shouldn't reset the viewer's filters).
392
+ init(report) {
393
+ this._groups.clear();
394
+ const validIds = new Set();
395
+ for (const group of report.filterGroups ?? []) {
396
+ this._groups.set(group.id, group);
397
+ validIds.add(group.id);
398
+ if (!this._values.has(group.id)) {
399
+ const defaultValue = this._defaultValue(group);
400
+ if (defaultValue) {
401
+ this._values.set(group.id, defaultValue);
402
+ }
403
+ }
404
+ }
405
+ for (const groupId of [...this._values.keys()]) {
406
+ if (!validIds.has(groupId)) {
407
+ this._values.delete(groupId);
408
+ }
409
+ }
410
+ this._changed$.next(null);
411
+ }
412
+ value(groupId) {
413
+ return this._values.get(groupId);
414
+ }
415
+ setValue(groupId, value) {
416
+ if (value === null) {
417
+ this._values.delete(groupId);
418
+ }
419
+ else {
420
+ this._values.set(groupId, value);
421
+ }
422
+ this._changed$.next(groupId);
423
+ }
424
+ // The active Frequency override, or null when the viewer hasn't chosen one.
425
+ frequency() {
426
+ return this._frequency;
427
+ }
428
+ setFrequency(frequency) {
429
+ this._frequency = frequency;
430
+ this._frequencyChanged$.next();
431
+ }
432
+ // Fires whenever the Frequency changes — every time-series chart listens so it
433
+ // refetches and re-buckets (the override isn't scoped to any filter group, so
434
+ // changesFor() can't carry it).
435
+ frequencyChanges() {
436
+ return this._frequencyChanged$.asObservable();
437
+ }
438
+ isFilterDisabled(filterId) {
439
+ return this._sessionDisabled.has(filterId);
440
+ }
441
+ toggleFilter(filterId, groupId) {
442
+ if (this._sessionDisabled.has(filterId)) {
443
+ this._sessionDisabled.delete(filterId);
444
+ }
445
+ else {
446
+ this._sessionDisabled.add(filterId);
447
+ }
448
+ this._changed$.next(groupId);
449
+ }
450
+ // Notifications relevant to ONE component: any change to a group one of its
451
+ // filters belongs to (or a full re-init).
452
+ changesFor(component) {
453
+ const groupIds = new Set((component.filters ?? []).map((filter) => filter.filterGroupId));
454
+ return this._changed$.asObservable()
455
+ .pipe(filter((groupId) => groupId === null || groupIds.has(groupId)));
456
+ }
457
+ // The group values resolved into this component's member-filter values —
458
+ // what the data request carries. Disabled filters (authored or session) are
459
+ // skipped entirely.
460
+ resolveForComponent(component) {
461
+ const resolved = [];
462
+ for (const filter of component.filters ?? []) {
463
+ if (!filter.enabled || this._sessionDisabled.has(filter.id)) {
464
+ continue;
465
+ }
466
+ const value = this._values.get(filter.filterGroupId);
467
+ if (!value) {
468
+ continue;
469
+ }
470
+ const entry = { filterId: filter.id };
471
+ if (value.start) {
472
+ entry.start = dateBoundString(value.start);
473
+ }
474
+ if (value.end) {
475
+ entry.end = dateBoundString(value.end);
476
+ }
477
+ if (value.values?.length) {
478
+ entry.values = value.values;
479
+ }
480
+ if (value.value?.trim()) {
481
+ entry.value = value.value.trim();
482
+ }
483
+ if (entry.start || entry.end || entry.values || entry.value) {
484
+ resolved.push(entry);
485
+ }
486
+ }
487
+ return resolved;
488
+ }
489
+ // A group's authored default — relative presets resolve against "now" each
490
+ // time the report opens, so a saved "last 12 months" stays relative.
491
+ _defaultValue(group) {
492
+ const groupDefault = group.config?.default;
493
+ if (!groupDefault) {
494
+ return null;
495
+ }
496
+ if (groupDefault.relative) {
497
+ const range = this._relativeRange(groupDefault.relative);
498
+ return range ? { start: range.start, end: range.end } : null;
499
+ }
500
+ if (groupDefault.start || groupDefault.end) {
501
+ return {
502
+ start: groupDefault.start ?? null,
503
+ end: groupDefault.end ?? null,
504
+ };
505
+ }
506
+ return null;
507
+ }
508
+ _relativeRange(relative) {
509
+ const now = new Date();
510
+ switch (relative) {
511
+ case 'last7Days':
512
+ return { start: subDays(now, 7), end: now };
513
+ case 'last30Days':
514
+ return { start: subDays(now, 30), end: now };
515
+ case 'last3Months':
516
+ return { start: subMonths(now, 3), end: now };
517
+ case 'last6Months':
518
+ return { start: subMonths(now, 6), end: now };
519
+ case 'last12Months':
520
+ return { start: subMonths(now, 12), end: now };
521
+ case 'thisMonth':
522
+ return { start: startOfMonth(now), end: now };
523
+ case 'thisQuarter':
524
+ return { start: startOfQuarter(now), end: now };
525
+ case 'thisYear':
526
+ case 'yearToDate':
527
+ return { start: startOfYear(now), end: now };
528
+ default:
529
+ return null;
530
+ }
531
+ }
532
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportFilterStateService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
533
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportFilterStateService });
534
+ }
535
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportFilterStateService, decorators: [{
536
+ type: Injectable
537
+ }] });
538
+
539
+ const BUCKET_GRANULARITIES = ['day', 'week', 'month', 'quarter', 'year'];
540
+ // colorBy applies only to single-dimension bar charts, and only when splitBy
541
+ // isn't already driving multiple series: it colors each bar by its category and
542
+ // shows a legend (one series per category, overlapped to full width).
543
+ function isColorBy(config) {
544
+ return !!config.colorBy?.column && !config.splitBy?.column && (config.chartType ?? 'bar') === 'bar';
545
+ }
546
+ function extractChartSeries(component, data) {
547
+ const config = component.config;
548
+ const xColumn = config.xAxis?.column ?? data.columns[0];
549
+ const splitColumn = config.splitBy?.column;
550
+ // colorBy emits one series per category too (so each bar carries its own
551
+ // color + legend entry), but the bars are overlapped to full width in
552
+ // buildAxisOption rather than laid side-by-side. splitBy wins if both are set.
553
+ const groupColumn = splitColumn || (isColorBy(config) ? config.colorBy?.column : undefined);
554
+ const measures = config.measures?.length
555
+ ? config.measures
556
+ : [{ column: data.columns[data.columns.length - 1] }];
557
+ const rawCategories = uniqueValues(data.rows, xColumn);
558
+ const categories = rawCategories.map((category) => String(category ?? '—'));
559
+ const series = [];
560
+ if (groupColumn) {
561
+ // One series per group value (splitBy/colorBy), pivoted over the first measure.
562
+ const measure = measures[0];
563
+ for (const groupValue of uniqueValues(data.rows, groupColumn)) {
564
+ const byCategory = new Map();
565
+ for (const row of data.rows) {
566
+ if (row[groupColumn] === groupValue) {
567
+ byCategory.set(row[xColumn], row[measure.column]);
568
+ }
569
+ }
570
+ series.push({
571
+ name: String(groupValue ?? '—'),
572
+ values: rawCategories.map((category) => numeric(byCategory.get(category))),
573
+ });
574
+ }
575
+ }
576
+ else {
577
+ for (const measure of measures) {
578
+ const byCategory = new Map();
579
+ for (const row of data.rows) {
580
+ byCategory.set(row[xColumn], row[measure.column]);
581
+ }
582
+ series.push({
583
+ name: measure.label ?? measure.column,
584
+ values: rawCategories.map((category) => numeric(byCategory.get(category))),
585
+ });
586
+ }
587
+ }
588
+ return { categories, series };
589
+ }
590
+ // The one pure mapping from our standard camelCase chart config + the data
591
+ // endpoint's rows to an ECharts option. Everything visual that isn't in the
592
+ // registered 'report' theme is decided here, so report standards have exactly
593
+ // two homes.
594
+ function buildChartOption(component, data) {
595
+ const config = component.config;
596
+ const chartType = config.chartType ?? 'bar';
597
+ if (chartType === 'pie' || chartType === 'donut') {
598
+ return buildPieOption(component, data, chartType === 'donut');
599
+ }
600
+ return buildAxisOption(component, data, chartType);
601
+ }
602
+ function buildAxisOption(component, data, chartType) {
603
+ const config = component.config;
604
+ const extracted = extractChartSeries(component, data);
605
+ const horizontal = config.orientation === 'horizontal';
606
+ const colorBy = isColorBy(config);
607
+ // colorBy emits one series per category; overlapping them (barGap -100%) makes
608
+ // each render at the full category-band width — one full-width, distinctly
609
+ // colored bar per category — instead of side-by-side slivers. The layout props
610
+ // are read from the first series of the coordinate system.
611
+ const series = extracted.series.map((extractedSeries, index) => buildSeries(extractedSeries.name, extractedSeries.values, chartType, config.stacked, colorBy && index === 0));
612
+ const granularity = bucketGranularity(data);
613
+ const categoryAxis = {
614
+ type: 'category',
615
+ data: extracted.categories,
616
+ name: config.xAxis?.label,
617
+ nameLocation: 'middle',
618
+ nameGap: granularity && spansMultipleYears(extracted.categories) ? 40 : 28,
619
+ ...periodAxisLabel(granularity, extracted.categories),
620
+ };
621
+ const valueAxis = { type: 'value' };
622
+ const legendPosition = resolveLegendPosition(config, series.length, colorBy);
623
+ return {
624
+ grid: axisGrid(legendPosition),
625
+ legend: legendOption(legendPosition),
626
+ // colorBy charts overlap N mostly-null series, so an axis tooltip would list
627
+ // every category; an item tooltip shows just the hovered bar.
628
+ tooltip: { trigger: colorBy ? 'item' : 'axis' },
629
+ xAxis: horizontal ? valueAxis : categoryAxis,
630
+ yAxis: horizontal ? categoryAxis : valueAxis,
631
+ series,
632
+ };
633
+ }
634
+ // Default legend placement when the config doesn't pin one: pie charts read
635
+ // best with the slice key beside them (right); axis charts only need a legend
636
+ // to tell multiple series apart, so a single-series chart hides it. An explicit
637
+ // config.legend (including 'hidden') always wins. Shared with the PowerPoint
638
+ // export so both media place the legend identically.
639
+ function resolveLegendPosition(config, seriesCount, colorBy = false) {
640
+ if (config.legend) {
641
+ return config.legend;
642
+ }
643
+ const pie = config.chartType === 'pie' || config.chartType === 'donut';
644
+ // colorBy maps each bar's color to its category, so it needs a legend even
645
+ // though it's a single measure.
646
+ return pie || colorBy || seriesCount > 1 ? (pie ? 'right' : 'top') : 'hidden';
647
+ }
648
+ function legendOption(position) {
649
+ if (position === 'hidden') {
650
+ return { show: false };
651
+ }
652
+ const base = { type: 'scroll' };
653
+ switch (position) {
654
+ case 'bottom':
655
+ return { ...base, bottom: 0 };
656
+ case 'left':
657
+ return { ...base, orient: 'vertical', left: 0, top: 'middle' };
658
+ case 'right':
659
+ return { ...base, orient: 'vertical', right: 0, top: 'middle' };
660
+ default:
661
+ return { ...base, top: 0 };
662
+ }
663
+ }
664
+ // Reserve plot-edge room on whichever side the legend occupies so it never
665
+ // overlaps the bars/lines; containLabel already accounts for the axis labels.
666
+ function axisGrid(position) {
667
+ const base = { left: 8, right: 16, top: 8, bottom: 8, containLabel: true };
668
+ switch (position) {
669
+ case 'bottom':
670
+ return { ...base, bottom: 32 };
671
+ case 'left':
672
+ return { ...base, left: 96 };
673
+ case 'right':
674
+ return { ...base, right: 96 };
675
+ case 'hidden':
676
+ return base;
677
+ default:
678
+ return { ...base, top: 32 };
679
+ }
680
+ }
681
+ function buildSeries(name, values, chartType, stacked, overlap) {
682
+ if (chartType === 'bar') {
683
+ return {
684
+ name,
685
+ type: 'bar',
686
+ data: values,
687
+ stack: stacked ? 'total' : undefined,
688
+ barMaxWidth: 48,
689
+ // colorBy: overlap the per-category series so each bar renders full-width.
690
+ ...(overlap ? { barGap: '-100%', barCategoryGap: '20%' } : {}),
691
+ };
692
+ }
693
+ return {
694
+ name,
695
+ type: 'line',
696
+ data: values,
697
+ stack: stacked ? 'total' : undefined,
698
+ smooth: true,
699
+ symbolSize: 6,
700
+ areaStyle: chartType === 'area' ? { opacity: 0.25 } : undefined,
701
+ };
702
+ }
703
+ function buildPieOption(component, data, donut) {
704
+ const config = component.config;
705
+ const extracted = extractChartSeries(component, data);
706
+ const values = extracted.series[0]?.values ?? [];
707
+ const legendPosition = resolveLegendPosition(config, extracted.series.length);
708
+ return {
709
+ tooltip: { trigger: 'item' },
710
+ legend: legendOption(legendPosition),
711
+ series: [
712
+ {
713
+ type: 'pie',
714
+ radius: donut ? ['45%', '72%'] : '72%',
715
+ center: pieCenter(legendPosition),
716
+ label: { show: false },
717
+ data: extracted.categories.map((category, index) => ({
718
+ name: category,
719
+ value: values[index] ?? 0,
720
+ })),
721
+ },
722
+ ],
723
+ };
724
+ }
725
+ // Nudge the pie off-center toward the opposite edge from its legend so the two
726
+ // don't collide; centered when the legend is hidden.
727
+ function pieCenter(position) {
728
+ switch (position) {
729
+ case 'left':
730
+ return ['60%', '50%'];
731
+ case 'right':
732
+ return ['40%', '50%'];
733
+ case 'top':
734
+ return ['50%', '58%'];
735
+ case 'bottom':
736
+ return ['50%', '46%'];
737
+ default:
738
+ return ['50%', '50%'];
739
+ }
740
+ }
741
+ function uniqueValues(rows, column) {
742
+ const seen = new Set();
743
+ const values = [];
744
+ for (const row of rows) {
745
+ const value = row[column];
746
+ if (!seen.has(value)) {
747
+ seen.add(value);
748
+ values.push(value);
749
+ }
750
+ }
751
+ return values;
752
+ }
753
+ function numeric(value) {
754
+ if (value === null || value === undefined || value === '') {
755
+ return null;
756
+ }
757
+ const parsed = Number(value);
758
+ return Number.isFinite(parsed) ? parsed : null;
759
+ }
760
+ // The effective time bucket the rows were grouped into (from the data endpoint),
761
+ // or null when the x-axis isn't a time axis — gates the compact period labels.
762
+ function bucketGranularity(data) {
763
+ return data.granularity && BUCKET_GRANULARITIES.includes(data.granularity) ? data.granularity : null;
764
+ }
765
+ // Whether the period-start categories cross a year boundary. When they don't,
766
+ // the year is pure noise on every label, so it's dropped entirely.
767
+ function spansMultipleYears(categories) {
768
+ const years = new Set();
769
+ for (const category of categories) {
770
+ const date = parseISO(category);
771
+ if (isValid(date)) {
772
+ years.add(date.getFullYear());
773
+ }
774
+ }
775
+ return years.size > 1;
776
+ }
777
+ // A bucket's period-start split into its primary label and its year. Day/week
778
+ // read as "May 1", month as "May", quarter as "Q2", year as "2025". Returns null
779
+ // for a value that isn't a parseable date (left as-is by callers).
780
+ function periodLabelParts(value, granularity) {
781
+ const date = parseISO(value);
782
+ if (!isValid(date)) {
783
+ return null;
784
+ }
785
+ const year = String(date.getFullYear());
786
+ switch (granularity) {
787
+ case 'year':
788
+ return { primary: year, year };
789
+ case 'quarter':
790
+ return { primary: `Q${Math.floor(date.getMonth() / 3) + 1}`, year };
791
+ case 'month':
792
+ return { primary: format(date, 'MMMM'), year };
793
+ default: // day, week — the bucket start as a compact "MMM d"
794
+ return { primary: format(date, 'MMM d'), year };
795
+ }
796
+ }
797
+ // A compact single-line period label (year appended only when the data spans
798
+ // years) — for media that can't do a styled second line, e.g. PowerPoint.
799
+ function formatPeriodLabel(value, granularity, multiYear) {
800
+ const parts = periodLabelParts(value, granularity);
801
+ if (!parts) {
802
+ return value;
803
+ }
804
+ return multiYear && granularity !== 'year' ? `${parts.primary} ${parts.year}` : parts.primary;
805
+ }
806
+ // ECharts category-axis label config for a time bucket: the primary label on
807
+ // line one, and — only when the data spans multiple years — the year on a small
808
+ // gray second line, centered under the tick (year granularity needs no second
809
+ // line, it already is the year). Empty for a non-time axis (labels as-is).
810
+ function periodAxisLabel(granularity, categories) {
811
+ if (!granularity) {
812
+ return {};
813
+ }
814
+ const multiYear = spansMultipleYears(categories);
815
+ return {
816
+ axisLabel: {
817
+ formatter: (value) => {
818
+ const parts = periodLabelParts(value, granularity);
819
+ if (!parts) {
820
+ return value;
821
+ }
822
+ return multiYear && granularity !== 'year'
823
+ ? `${parts.primary}\n{yr|${parts.year}}`
824
+ : parts.primary;
825
+ },
826
+ rich: { yr: { fontSize: 10, color: '#9aa0a6', lineHeight: 14 } },
827
+ },
828
+ };
829
+ }
830
+
831
+ // ECharts host for a chart component. Pure rendering: the parent owns the
832
+ // data fetch; this maps (component config, rows) → option and exposes the
833
+ // chart instance for resize + the future image exports (getDataURL needs the
834
+ // canvas renderer registered in theme/echarts.ts).
835
+ class ComponentChartComponent {
836
+ component;
837
+ data = null;
838
+ options = null;
839
+ _chart = null;
840
+ ngOnChanges() {
841
+ this.options = this.component && this.data
842
+ ? buildChartOption(this.component, this.data)
843
+ : null;
844
+ }
845
+ onChartInit(chart) {
846
+ this._chart = chart;
847
+ }
848
+ // Called by the canvas when the box is resized in edit mode.
849
+ resize() {
850
+ this._chart?.resize();
851
+ }
852
+ // High-res image for the (later-phase) PPT/PDF exports.
853
+ getDataURL(pixelRatio = 3) {
854
+ return this._chart?.getDataURL({
855
+ type: 'png',
856
+ pixelRatio,
857
+ backgroundColor: '#ffffff',
858
+ }) ?? null;
859
+ }
860
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentChartComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
861
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.14", type: ComponentChartComponent, isStandalone: true, selector: "app-report-component-chart", inputs: { component: "component", data: "data" }, usesOnChanges: true, ngImport: i0, template: "<div\n class=\"chart-host\"\n echarts\n [options]=\"options ?? {}\"\n [theme]=\"'report'\"\n [autoResize]=\"true\"\n (chartInit)=\"onChartInit($event)\">\n</div>\n", styles: [":host{display:block;width:100%;height:100%}.chart-host{width:100%;height:100%}\n"], dependencies: [{ kind: "directive", type: NgxEchartsDirective, selector: "echarts, [echarts]", inputs: ["options", "theme", "initOpts", "merge", "autoResize", "loading", "loadingType", "loadingOpts"], outputs: ["chartInit", "optionsError", "chartClick", "chartDblClick", "chartMouseDown", "chartMouseMove", "chartMouseUp", "chartMouseOver", "chartMouseOut", "chartGlobalOut", "chartContextMenu", "chartHighlight", "chartDownplay", "chartSelectChanged", "chartLegendSelectChanged", "chartLegendSelected", "chartLegendUnselected", "chartLegendLegendSelectAll", "chartLegendLegendInverseSelect", "chartLegendScroll", "chartDataZoom", "chartDataRangeSelected", "chartGraphRoam", "chartGeoRoam", "chartTreeRoam", "chartTimelineChanged", "chartTimelinePlayChanged", "chartRestore", "chartDataViewChanged", "chartMagicTypeChanged", "chartGeoSelectChanged", "chartGeoSelected", "chartGeoUnselected", "chartAxisAreaSelected", "chartBrush", "chartBrushEnd", "chartBrushSelected", "chartGlobalCursorTaken", "chartRendered", "chartFinished"], exportAs: ["echarts"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
862
+ }
863
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentChartComponent, decorators: [{
864
+ type: Component,
865
+ args: [{ selector: 'app-report-component-chart', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
866
+ NgxEchartsDirective,
867
+ ], template: "<div\n class=\"chart-host\"\n echarts\n [options]=\"options ?? {}\"\n [theme]=\"'report'\"\n [autoResize]=\"true\"\n (chartInit)=\"onChartInit($event)\">\n</div>\n", styles: [":host{display:block;width:100%;height:100%}.chart-host{width:100%;height:100%}\n"] }]
868
+ }], propDecorators: { component: [{
869
+ type: Input
870
+ }], data: [{
871
+ type: Input
872
+ }] } });
873
+
874
+ // Measure-value formatting shared by the KPI renderer and the exports — the
875
+ // config's `format` (number | percent | currency) means the same thing on
876
+ // screen, in PowerPoint and in PDF.
877
+ function formatMeasureValue(value, format) {
878
+ if (value === null || value === undefined || value === '') {
879
+ return '—';
880
+ }
881
+ const numeric = Number(value);
882
+ if (!Number.isFinite(numeric)) {
883
+ return String(value);
884
+ }
885
+ switch (format) {
886
+ case 'percent':
887
+ return `${new Intl.NumberFormat().format(numeric)}%`;
888
+ case 'currency':
889
+ return new Intl.NumberFormat(undefined, {
890
+ style: 'currency',
891
+ currency: 'USD',
892
+ maximumFractionDigits: 0,
893
+ }).format(numeric);
894
+ default:
895
+ return new Intl.NumberFormat().format(numeric);
896
+ }
897
+ }
898
+
899
+ // A single big number: the component's measure aggregated over all (filtered)
900
+ // rows server-side; this just formats the one cell.
901
+ class ComponentKpiComponent {
902
+ component;
903
+ data = null;
904
+ value = '—';
905
+ label = '';
906
+ ngOnChanges() {
907
+ const config = (this.component?.config ?? {});
908
+ this.label = config.measure?.label ?? '';
909
+ const row = this.data?.rows?.[0];
910
+ if (!row) {
911
+ this.value = '—';
912
+ return;
913
+ }
914
+ const column = config.measure?.column ?? Object.keys(row)[0];
915
+ this.value = formatMeasureValue(row[column], config.measure?.format);
916
+ }
917
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentKpiComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
918
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentKpiComponent, isStandalone: true, selector: "app-report-component-kpi", inputs: { component: "component", data: "data" }, usesOnChanges: true, ngImport: i0, template: "<div class=\"kpi\">\n <div class=\"kpi-value\">{{ value }}</div>\n @if (label) {\n <div class=\"kpi-label\">{{ label }}</div>\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%}.kpi{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px}.kpi .kpi-value{font-size:34px;font-weight:600;color:#1f2933;line-height:1}.kpi .kpi-label{font-size:12px;color:#7b8794}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
919
+ }
920
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentKpiComponent, decorators: [{
921
+ type: Component,
922
+ args: [{ selector: 'app-report-component-kpi', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: "<div class=\"kpi\">\n <div class=\"kpi-value\">{{ value }}</div>\n @if (label) {\n <div class=\"kpi-label\">{{ label }}</div>\n }\n</div>\n", styles: [":host{display:block;width:100%;height:100%}.kpi{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px}.kpi .kpi-value{font-size:34px;font-weight:600;color:#1f2933;line-height:1}.kpi .kpi-label{font-size:12px;color:#7b8794}\n"] }]
923
+ }], propDecorators: { component: [{
924
+ type: Input
925
+ }], data: [{
926
+ type: Input
927
+ }] } });
928
+
929
+ // A list component: FsList + FsFilter rendered from the component's config and
930
+ // its report_filters rows. The component's own filter bar carries its filters
931
+ // whose group renders at the component level; report-level groups apply
932
+ // through the shared filter-state service and merge into every request.
933
+ // Paging and sorting are pushed into the SQL wrap server-side.
934
+ class ComponentListComponent {
935
+ reportId;
936
+ component;
937
+ groups;
938
+ listComponent;
939
+ listConfig;
940
+ columns = [];
941
+ _reportData = inject(ReportData);
942
+ _filterState = inject(ReportFilterStateService);
943
+ _destroyRef = inject(DestroyRef);
944
+ ngOnInit() {
945
+ const config = (this.component.config ?? {});
946
+ this.columns = config.columns ?? [];
947
+ this._initListConfig(config);
948
+ // Report-level filter changes that scope this component re-run the list.
949
+ this._filterState.changesFor(this.component)
950
+ .pipe(takeUntilDestroyed(this._destroyRef))
951
+ .subscribe(() => this.listComponent?.reload());
952
+ }
953
+ _initListConfig(config) {
954
+ this.listConfig = {
955
+ // Inside a report component the list is a clean data pane — no status
956
+ // bar, no reload button (filter changes already refetch).
957
+ status: false,
958
+ reload: false,
959
+ // Never touch the URL or persist filter/paging state — report filtering
960
+ // is session-only.
961
+ queryParam: false,
962
+ persist: false,
963
+ sort: config.sort
964
+ ? { value: config.sort.column, direction: config.sort.direction ?? 'asc' }
965
+ : undefined,
966
+ paging: {
967
+ limit: config.pageSize ?? 25,
968
+ strategy: PaginationStrategy.Page,
969
+ },
970
+ filters: this._ownFilters().map((filter) => this._filterItem(filter)),
971
+ fetch: (query) => this._fetch(query),
972
+ };
973
+ }
974
+ // The filters this list renders in its own bar: its report_filters whose
975
+ // group is component-level. Report-level groups render in the report bar.
976
+ _ownFilters() {
977
+ return (this.component.filters ?? [])
978
+ .filter((filter) => {
979
+ const group = this.groups?.get(filter.filterGroupId);
980
+ return filter.enabled && (!group || group.level === 'component' || group.level === 'both');
981
+ });
982
+ }
983
+ _filterItem(filter) {
984
+ const group = this.groups?.get(filter.filterGroupId);
985
+ const name = `f${filter.id}`;
986
+ const label = filter.label || group?.label || filter.filterColumn;
987
+ switch (group?.type) {
988
+ case 'dateRange':
989
+ return {
990
+ name,
991
+ type: ItemType.DateRange,
992
+ label: [`${label} From`, `${label} To`],
993
+ };
994
+ case 'select':
995
+ return {
996
+ name,
997
+ type: ItemType.AutoCompleteChips,
998
+ label,
999
+ values: () => this._reportData.filterOptions(this.reportId, filter.id)
1000
+ .pipe(map$1((options) => (options ?? [])
1001
+ .map((option) => ({ name: String(option), value: option })))),
1002
+ };
1003
+ case 'keyword':
1004
+ default:
1005
+ return {
1006
+ name,
1007
+ type: ItemType.Keyword,
1008
+ label,
1009
+ };
1010
+ }
1011
+ }
1012
+ _fetch(query) {
1013
+ const state = {
1014
+ page: query.page ?? 1,
1015
+ pageSize: query.limit ?? 25,
1016
+ };
1017
+ const order = String(query.order ?? '');
1018
+ if (order) {
1019
+ const [column, direction] = order.split(',');
1020
+ state.sort = { column, direction: direction === 'desc' ? 'desc' : 'asc' };
1021
+ }
1022
+ // Report-level values from the shared session state, merged with this
1023
+ // list's own filter-bar values (own values win per filter).
1024
+ const resolved = new Map();
1025
+ for (const value of this._filterState.resolveForComponent(this.component)) {
1026
+ resolved.set(value.filterId, value);
1027
+ }
1028
+ for (const value of this._barValues(query)) {
1029
+ resolved.set(value.filterId, value);
1030
+ }
1031
+ return this._reportData
1032
+ .componentData(this.reportId, this.component.id, [...resolved.values()], state)
1033
+ .pipe(map$1((data) => ({
1034
+ data: data.rows ?? [],
1035
+ paging: {
1036
+ limit: data.paging?.pageSize ?? state.pageSize,
1037
+ records: data.paging?.total ?? (data.rows?.length ?? 0),
1038
+ page: data.paging?.page ?? state.page,
1039
+ },
1040
+ })));
1041
+ }
1042
+ // The list's own FsFilter values, read back out of the fetch query by the
1043
+ // `f<filterId>` naming convention.
1044
+ _barValues(query) {
1045
+ const values = [];
1046
+ for (const filter of this._ownFilters()) {
1047
+ const group = this.groups?.get(filter.filterGroupId);
1048
+ const name = `f${filter.id}`;
1049
+ if (group?.type === 'dateRange') {
1050
+ const from = dateBoundString(query[`${name}From`]);
1051
+ const to = dateBoundString(query[`${name}To`]);
1052
+ if (from || to) {
1053
+ values.push({ filterId: filter.id, start: from, end: to });
1054
+ }
1055
+ }
1056
+ else if (group?.type === 'select') {
1057
+ // fs-filter joins a multi-select into one "a,b" string; split it back
1058
+ // so each value binds separately (else `col IN ('a,b')` matches nothing).
1059
+ const selected = query[name];
1060
+ const selectedValues = Array.isArray(selected)
1061
+ ? selected
1062
+ : (selected ? String(selected).split(',') : []);
1063
+ if (selectedValues.length) {
1064
+ values.push({ filterId: filter.id, values: selectedValues });
1065
+ }
1066
+ }
1067
+ else {
1068
+ const keyword = String(query[name] ?? '').trim();
1069
+ if (keyword) {
1070
+ values.push({ filterId: filter.id, value: keyword });
1071
+ }
1072
+ }
1073
+ }
1074
+ return values;
1075
+ }
1076
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1077
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentListComponent, isStandalone: true, selector: "app-report-component-list", inputs: { reportId: "reportId", component: "component", groups: "groups" }, viewQueries: [{ propertyName: "listComponent", first: true, predicate: FsListComponent, descendants: true }], ngImport: i0, template: "<fs-list [config]=\"listConfig\">\n @for (column of columns; track column.column) {\n <fs-list-column\n [name]=\"column.column\"\n [title]=\"column.label || column.column\"\n [sortable]=\"true\">\n <ng-template\n fs-list-cell\n let-row=\"row\">\n @if (column.format === 'date') {\n {{ row[column.column] | date: 'mediumDate' }}\n } @else {\n {{ row[column.column] }}\n }\n </ng-template>\n </fs-list-column>\n }\n</fs-list>\n", styles: [":host{display:block;width:100%;height:100%;overflow:auto}\n"], dependencies: [{ kind: "pipe", type: DatePipe, name: "date" }, { kind: "ngmodule", type: FsListModule }, { kind: "component", type: i1.FsListComponent, selector: "fs-list", inputs: ["config", "loaderLines", "cellRowType"], outputs: ["filtersReady"] }, { kind: "directive", type: i1.FsListColumnDirective, selector: "fs-list-column", inputs: ["show", "title", "name", "customizable", "sortable", "sortableDefault", "sortableDirection", "direction", "align", "width", "class"] }, { kind: "directive", type: i1.FsListCellDirective, selector: "[fs-list-cell],[fsListCell]", inputs: ["colspan", "align", "class", "fsListCell", "configTyping"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1078
+ }
1079
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentListComponent, decorators: [{
1080
+ type: Component,
1081
+ args: [{ selector: 'app-report-component-list', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
1082
+ DatePipe,
1083
+ FsListModule,
1084
+ ], template: "<fs-list [config]=\"listConfig\">\n @for (column of columns; track column.column) {\n <fs-list-column\n [name]=\"column.column\"\n [title]=\"column.label || column.column\"\n [sortable]=\"true\">\n <ng-template\n fs-list-cell\n let-row=\"row\">\n @if (column.format === 'date') {\n {{ row[column.column] | date: 'mediumDate' }}\n } @else {\n {{ row[column.column] }}\n }\n </ng-template>\n </fs-list-column>\n }\n</fs-list>\n", styles: [":host{display:block;width:100%;height:100%;overflow:auto}\n"] }]
1085
+ }], propDecorators: { reportId: [{
1086
+ type: Input
1087
+ }], component: [{
1088
+ type: Input
1089
+ }], groups: [{
1090
+ type: Input
1091
+ }], listComponent: [{
1092
+ type: ViewChild,
1093
+ args: [FsListComponent]
1094
+ }] } });
1095
+
1096
+ // Freeform resize handles, named by compass direction.
1097
+ const HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
1098
+ // Flow width snaps to the useful fractions of a page row.
1099
+ const FLOW_WIDTH_STOPS = [25, 33.33, 50, 66.67, 75, 100];
1100
+ const MIN_W = 0.6; // inches
1101
+ const MIN_H = 0.4; // inches
1102
+ // One component on the page: title bar, the type-specific body (chart | list
1103
+ // | kpi), a menu (settings, filter toggles, export) — and the editing
1104
+ // interactions:
1105
+ //
1106
+ // Freeform: click selects; the selection frame shows 8 resize handles;
1107
+ // dragging the body moves with smart alignment guides (via the canvas
1108
+ // snapper); everything works at any zoom (pixel deltas ÷ zoom·96 → inches).
1109
+ //
1110
+ // Flow: dragging reorders (drop position decided by the canvas); the E
1111
+ // handle resizes flowWidth % (snapping to 25/33/50/66/75/100), the S handle
1112
+ // resizes height in inches.
1113
+ //
1114
+ // Charts and KPIs fetch their own data here; lists fetch inside
1115
+ // ComponentListComponent through FsList paging.
1116
+ class ReportComponentComponent {
1117
+ reportId;
1118
+ component;
1119
+ groups;
1120
+ editMode = false;
1121
+ layout = 'freeform';
1122
+ // The canvas zoom — converts on-screen pixel deltas back to inches.
1123
+ zoom = 1;
1124
+ snapper = null;
1125
+ selected = false;
1126
+ selectComponent = new EventEmitter();
1127
+ positionChanged = new EventEmitter();
1128
+ flowReorder = new EventEmitter();
1129
+ openSettings = new EventEmitter();
1130
+ chartComponent;
1131
+ // Freeform resize handles by type: a fixed-height component (chart) gets all
1132
+ // eight (resize width AND height); an auto-height one (list/kpi) gets only the
1133
+ // side handles since its height fits its content and can't be set.
1134
+ get resizeHandles() {
1135
+ return this._heightAuto ? ['e', 'w'] : HANDLES;
1136
+ }
1137
+ // The component's header IS an fs-filter: heading template = its title (and
1138
+ // the drag handle), actions = the Settings / filter-toggles / Export menu,
1139
+ // items = its component-level filter groups (none for lists — they filter
1140
+ // through FsList). Rebuilt when a filter toggle changes which groups show.
1141
+ filterConfig;
1142
+ data = null;
1143
+ loading = false;
1144
+ error = null;
1145
+ _refresh$ = new Subject();
1146
+ _reportData = inject(ReportData);
1147
+ _filterState = inject(ReportFilterStateService);
1148
+ _process = inject(FsProcess);
1149
+ _destroyRef = inject(DestroyRef);
1150
+ _cdRef = inject(ChangeDetectorRef);
1151
+ _zone = inject(NgZone);
1152
+ _host = inject(ElementRef);
1153
+ ngOnInit() {
1154
+ this._buildFilterConfig();
1155
+ if (this.component.type !== 'list') {
1156
+ // Debounced so a rapid filter sweep fires one query, not one per change.
1157
+ this._refresh$
1158
+ .pipe(debounceTime(300), takeUntilDestroyed(this._destroyRef))
1159
+ .subscribe(() => this._load());
1160
+ this._filterState.changesFor(this.component)
1161
+ .pipe(takeUntilDestroyed(this._destroyRef))
1162
+ .subscribe(() => this._refresh$.next());
1163
+ // A Frequency change re-buckets every time-series chart; it isn't scoped
1164
+ // to a filter group, so it rides its own stream.
1165
+ if (this._isTimeSeries) {
1166
+ this._filterState.frequencyChanges()
1167
+ .pipe(takeUntilDestroyed(this._destroyRef))
1168
+ .subscribe(() => this._refresh$.next());
1169
+ }
1170
+ this._load();
1171
+ }
1172
+ }
1173
+ // The report reloads (e.g. after the settings dialog adds/moves a filter)
1174
+ // hand this instance fresh `component`/`groups` inputs without re-running
1175
+ // ngOnInit. Rebuild the header config so the inline component-filter strip
1176
+ // reflects the new filters; the template recreates the fs-filter via
1177
+ // `filterKey` so it actually re-reads this config.
1178
+ ngOnChanges(changes) {
1179
+ const filtersChanged = (changes.component && !changes.component.firstChange)
1180
+ || (changes.groups && !changes.groups.firstChange);
1181
+ if (filtersChanged) {
1182
+ this._buildFilterConfig();
1183
+ }
1184
+ }
1185
+ // fs-filter reads its config only once at init, so the header is keyed on the
1186
+ // set of component-level filter groups: the block is recreated (re-reading
1187
+ // the rebuilt config) whenever that set changes.
1188
+ get filterKey() {
1189
+ return this.componentFilterGroups.map((group) => `${group.id}:${group.level}`).join('|');
1190
+ }
1191
+ get truncated() {
1192
+ return !!this.data?.truncated;
1193
+ }
1194
+ // Content padding (inches → px at 96/in; the canvas zoom scales the page as
1195
+ // a whole). Each edge defaults to 1/8".
1196
+ get bodyPadding() {
1197
+ const padding = this.component.config?.padding;
1198
+ const edge = (value) => `${(value ?? DEFAULT_COMPONENT_PADDING) * PX_PER_INCH}px`;
1199
+ return `${edge(padding?.top)} ${edge(padding?.right)} ${edge(padding?.bottom)} ${edge(padding?.left)}`;
1200
+ }
1201
+ // The component-level filter groups that should render as an inline control
1202
+ // strip on THIS component: a group is included when it's level=component and
1203
+ // has an enabled, session-on filter here. Report-level groups live in the
1204
+ // top bar instead; lists render their own filters through FsList, so they
1205
+ // never use this strip.
1206
+ get componentFilterGroups() {
1207
+ if (this.component.type === 'list' || !this.groups) {
1208
+ return [];
1209
+ }
1210
+ const groupIds = new Set();
1211
+ for (const filter of this.component.filters ?? []) {
1212
+ if (!filter.enabled || this._filterState.isFilterDisabled(filter.id)) {
1213
+ continue;
1214
+ }
1215
+ const group = this.groups.get(filter.filterGroupId);
1216
+ if (group && (group.level === 'component' || group.level === 'both')) {
1217
+ groupIds.add(group.id);
1218
+ }
1219
+ }
1220
+ return [...groupIds].map((id) => this.groups.get(id)).filter(Boolean);
1221
+ }
1222
+ settings() {
1223
+ this.openSettings.emit(this.component);
1224
+ }
1225
+ exportCsv() {
1226
+ const filters = this._filterState.resolveForComponent(this.component);
1227
+ this._process.download(`Exporting ${this.component.title ?? 'component'}`, this._reportData.exportComponent(this.reportId, this.component.id, filters));
1228
+ }
1229
+ // Any interaction inside a component must never start a canvas pan (the
1230
+ // zoom-pan host listens for mousedown/touchstart at the container level).
1231
+ stopCanvasPan(event) {
1232
+ event.stopPropagation();
1233
+ }
1234
+ onComponentPointerDown(event) {
1235
+ event.stopPropagation();
1236
+ if (this.editMode && !this.selected) {
1237
+ this.selectComponent.emit(this.component.id);
1238
+ }
1239
+ }
1240
+ // ----- edit-mode gestures ---------------------------------------------------
1241
+ onDragStart(event) {
1242
+ if (!this.editMode) {
1243
+ return;
1244
+ }
1245
+ this.selectComponent.emit(this.component.id);
1246
+ if (this.layout === 'flow') {
1247
+ this._flowDrag(event);
1248
+ return;
1249
+ }
1250
+ this._track(event, (deltaX, deltaY) => {
1251
+ let x = Math.max(0, this._origin.x + this._inches(deltaX));
1252
+ let y = Math.max(0, this._origin.y + this._inches(deltaY));
1253
+ if (this.snapper) {
1254
+ ({ x, y } = this.snapper.snapMove(this.component, x, y));
1255
+ }
1256
+ this.component.x = x;
1257
+ this.component.y = y;
1258
+ this._applyGeometry();
1259
+ }, () => this._emitGeometry());
1260
+ }
1261
+ onResizeStart(event, handle) {
1262
+ if (!this.editMode) {
1263
+ return;
1264
+ }
1265
+ this.selectComponent.emit(this.component.id);
1266
+ if (this.layout === 'flow') {
1267
+ this._flowResize(event, handle);
1268
+ return;
1269
+ }
1270
+ this._track(event, (deltaX, deltaY) => {
1271
+ const dx = this._inches(deltaX);
1272
+ const dy = this._inches(deltaY);
1273
+ let { x, y, w, h } = this._origin;
1274
+ if (handle.includes('e')) {
1275
+ w = Math.max(MIN_W, this._origin.w + dx);
1276
+ }
1277
+ if (handle.includes('s')) {
1278
+ h = Math.max(MIN_H, this._origin.h + dy);
1279
+ }
1280
+ if (handle.includes('w')) {
1281
+ const clamped = Math.min(dx, this._origin.w - MIN_W);
1282
+ x = this._origin.x + clamped;
1283
+ w = this._origin.w - clamped;
1284
+ }
1285
+ if (handle.includes('n')) {
1286
+ const clamped = Math.min(dy, this._origin.h - MIN_H);
1287
+ y = this._origin.y + clamped;
1288
+ h = this._origin.h - clamped;
1289
+ }
1290
+ let geometry = { x, y, w, h };
1291
+ if (this.snapper) {
1292
+ geometry = this.snapper.snapResize(this.component, geometry, handle);
1293
+ }
1294
+ Object.assign(this.component, geometry);
1295
+ this._applyGeometry();
1296
+ this.chartComponent?.resize();
1297
+ }, () => this._emitGeometry());
1298
+ }
1299
+ // (Re)build the header fs-filter config: heading template renders the title
1300
+ // and items are this component's component-level filter groups. The actions
1301
+ // menu (Settings / Export CSV) is NOT here — it's a standalone kebab floated
1302
+ // top-right in the template, so its button height no longer drives the header
1303
+ // row's height (which would otherwise inflate the heading by ~40px).
1304
+ _buildFilterConfig() {
1305
+ this.filterConfig = {
1306
+ // Never touch the URL or persist filter state — component filters are
1307
+ // session-only, owned by ReportFilterStateService.
1308
+ queryParam: false,
1309
+ persist: false,
1310
+ items: this.componentFilterGroups.map((group) => filterItemForGroup(group, this._reportData, this.reportId, this._filterState.value(group.id))),
1311
+ change: (query) => {
1312
+ for (const { groupId, value } of groupValuesFromQuery(query ?? {}, this.componentFilterGroups)) {
1313
+ this._filterState.setValue(groupId, value);
1314
+ }
1315
+ },
1316
+ };
1317
+ }
1318
+ // Flow: only the east handle resizes (width %). Height is always auto in flow
1319
+ // (the box fits its content), so there's no south handle here.
1320
+ _flowResize(event, handle) {
1321
+ const pageWidth = this._host.nativeElement.parentElement?.clientWidth || 1;
1322
+ this._track(event, (deltaX) => {
1323
+ if (handle.includes('e')) {
1324
+ const deltaPercent = (deltaX / this.zoom / pageWidth) * 100;
1325
+ const raw = Math.min(100, Math.max(10, this._origin.flowWidth + deltaPercent));
1326
+ this.component.flowWidth = this._snapFlowWidth(raw);
1327
+ this._host.nativeElement.style.width = `${this.component.flowWidth}%`;
1328
+ }
1329
+ }, () => {
1330
+ this.positionChanged.emit({
1331
+ componentId: this.component.id,
1332
+ flowWidth: this.component.flowWidth,
1333
+ });
1334
+ });
1335
+ }
1336
+ // Flow: drag lifts the component (ghost) and the canvas decides the drop
1337
+ // index from the pointer position.
1338
+ _flowDrag(event) {
1339
+ const host = this._host.nativeElement;
1340
+ let lastClientX = event.clientX;
1341
+ let lastClientY = event.clientY;
1342
+ host.classList.add('flow-dragging');
1343
+ this._track(event, (deltaX, deltaY, e) => {
1344
+ lastClientX = e.clientX;
1345
+ lastClientY = e.clientY;
1346
+ host.style.transform = `translate(${deltaX / this.zoom}px, ${deltaY / this.zoom}px)`;
1347
+ }, () => {
1348
+ host.classList.remove('flow-dragging');
1349
+ host.style.transform = '';
1350
+ this.flowReorder.emit({
1351
+ componentId: this.component.id,
1352
+ clientX: lastClientX,
1353
+ clientY: lastClientY,
1354
+ });
1355
+ });
1356
+ }
1357
+ _origin = { x: 0, y: 0, w: 0, h: 0, flowWidth: 50 };
1358
+ // Pointer-capture gesture loop: runs outside Angular, repaints imperatively,
1359
+ // and finishes back inside Angular (selection/persistence).
1360
+ _track(event, onMove, onEnd) {
1361
+ event.preventDefault();
1362
+ event.stopPropagation();
1363
+ const handle = event.target;
1364
+ handle.setPointerCapture(event.pointerId);
1365
+ this._origin = {
1366
+ x: this.component.x,
1367
+ y: this.component.y,
1368
+ w: this.component.w,
1369
+ h: this.component.h,
1370
+ flowWidth: this.component.flowWidth ?? 50,
1371
+ };
1372
+ const startX = event.clientX;
1373
+ const startY = event.clientY;
1374
+ this._zone.runOutsideAngular(() => {
1375
+ const move = (e) => onMove(e.clientX - startX, e.clientY - startY, e);
1376
+ const end = () => {
1377
+ handle.releasePointerCapture(event.pointerId);
1378
+ handle.removeEventListener('pointermove', move);
1379
+ handle.removeEventListener('pointerup', end);
1380
+ handle.removeEventListener('pointercancel', end);
1381
+ this._zone.run(() => {
1382
+ this.snapper?.clear();
1383
+ onEnd();
1384
+ this._cdRef.markForCheck();
1385
+ });
1386
+ };
1387
+ handle.addEventListener('pointermove', move);
1388
+ handle.addEventListener('pointerup', end);
1389
+ handle.addEventListener('pointercancel', end);
1390
+ });
1391
+ }
1392
+ _emitGeometry() {
1393
+ this.component.x = this._round(this.component.x);
1394
+ this.component.y = this._round(this.component.y);
1395
+ this.component.w = this._round(Math.max(MIN_W, this.component.w));
1396
+ this.component.h = this._round(Math.max(MIN_H, this.component.h));
1397
+ this._applyGeometry();
1398
+ this.chartComponent?.resize();
1399
+ this.positionChanged.emit({
1400
+ componentId: this.component.id,
1401
+ x: this.component.x,
1402
+ y: this.component.y,
1403
+ w: this.component.w,
1404
+ h: this.component.h,
1405
+ });
1406
+ }
1407
+ // A chart on a time x-axis — the only component the runtime Frequency control
1408
+ // applies to.
1409
+ get _isTimeSeries() {
1410
+ return this.component.type === 'chart' && this.component.config?.xAxis?.kind === 'time';
1411
+ }
1412
+ // Auto height (box fits its content) vs fixed (use `h` exactly). Derived from
1413
+ // type, server-side: lists and KPI tiles are auto; charts are always fixed so
1414
+ // they fit the box and never overflow.
1415
+ get _heightAuto() {
1416
+ return this.component.autoHeight !== false;
1417
+ }
1418
+ // Drives the auto-only CSS default (empty-state baseline) so it never applies
1419
+ // to a fixed-height component.
1420
+ get heightAuto() {
1421
+ return this._heightAuto;
1422
+ }
1423
+ // Screen pixels → inches at the current zoom.
1424
+ _inches(pixels) {
1425
+ return pixels / (this.zoom * PX_PER_INCH);
1426
+ }
1427
+ // Clean persisted values: hundredths of an inch.
1428
+ _round(value) {
1429
+ return Math.round(value * 100) / 100;
1430
+ }
1431
+ _snapFlowWidth(value) {
1432
+ for (const stop of FLOW_WIDTH_STOPS) {
1433
+ if (Math.abs(value - stop) < 3) {
1434
+ return stop;
1435
+ }
1436
+ }
1437
+ return Math.round(value * 10) / 10;
1438
+ }
1439
+ // The canvas positions the host via [style]; mid-gesture we set it directly
1440
+ // so OnPush never re-renders in the move loop.
1441
+ _applyGeometry() {
1442
+ const style = this._host.nativeElement.style;
1443
+ style.left = `${this.component.x * PX_PER_INCH}px`;
1444
+ style.top = `${this.component.y * PX_PER_INCH}px`;
1445
+ style.width = `${this.component.w * PX_PER_INCH}px`;
1446
+ // Auto (list/kpi): no fixed height — the box sizes to its content. Fixed
1447
+ // (chart): `h` is the exact height. Mirrors the canvas [style] binding so
1448
+ // the mid-gesture paint matches the post-gesture re-render.
1449
+ if (this._heightAuto) {
1450
+ style.height = '';
1451
+ }
1452
+ else {
1453
+ style.height = `${this.component.h * PX_PER_INCH}px`;
1454
+ }
1455
+ }
1456
+ // ----- data ----------------------------------------------------------------
1457
+ _load() {
1458
+ this.loading = true;
1459
+ this.error = null;
1460
+ this._cdRef.markForCheck();
1461
+ const filters = this._filterState.resolveForComponent(this.component);
1462
+ // Time-series charts carry the runtime Frequency override (if chosen) so the
1463
+ // backend re-buckets; everything else sends no interaction state here.
1464
+ const state = this._isTimeSeries && this._filterState.frequency()
1465
+ ? { frequency: this._filterState.frequency() }
1466
+ : {};
1467
+ this._reportData.componentData(this.reportId, this.component.id, filters, state)
1468
+ .pipe(takeUntilDestroyed(this._destroyRef))
1469
+ .subscribe({
1470
+ next: (data) => {
1471
+ this.data = data;
1472
+ this.loading = false;
1473
+ this._cdRef.markForCheck();
1474
+ },
1475
+ error: (error) => {
1476
+ this.loading = false;
1477
+ this.error = error?.error?.message ?? error?.message ?? 'Failed to load data';
1478
+ this._cdRef.markForCheck();
1479
+ },
1480
+ });
1481
+ }
1482
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1483
+ 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}: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 });
1484
+ }
1485
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponentComponent, decorators: [{
1486
+ type: Component,
1487
+ args: [{ selector: 'app-report-component', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
1488
+ MatIcon,
1489
+ MatIconButton,
1490
+ MatMenuModule,
1491
+ MatTooltip,
1492
+ FsFilterModule,
1493
+ ComponentChartComponent,
1494
+ ComponentKpiComponent,
1495
+ ComponentListComponent,
1496
+ ], 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}: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"] }]
1497
+ }], propDecorators: { reportId: [{
1498
+ type: Input
1499
+ }], component: [{
1500
+ type: Input
1501
+ }], groups: [{
1502
+ type: Input
1503
+ }], editMode: [{
1504
+ type: Input
1505
+ }], layout: [{
1506
+ type: Input
1507
+ }], zoom: [{
1508
+ type: Input
1509
+ }], snapper: [{
1510
+ type: Input
1511
+ }], selected: [{
1512
+ type: Input
1513
+ }], selectComponent: [{
1514
+ type: Output
1515
+ }], positionChanged: [{
1516
+ type: Output
1517
+ }], flowReorder: [{
1518
+ type: Output
1519
+ }], openSettings: [{
1520
+ type: Output
1521
+ }], chartComponent: [{
1522
+ type: ViewChild,
1523
+ args: [ComponentChartComponent]
1524
+ }], heightAuto: [{
1525
+ type: HostBinding,
1526
+ args: ['class.height-auto']
1527
+ }] } });
1528
+
1529
+ // Screen density of the canvas: 96 CSS px per inch (the CSS reference pixel),
1530
+ // before zoom. All geometry stays in inches; only rendering multiplies by it.
1531
+ const PX_PER_INCH = 96;
1532
+ // The fixed-size page canvas — the PowerPoint model. Pages are tabs (only the
1533
+ // ACTIVE page's components exist → only they fetch); the page is a
1534
+ // pageWidth×pageHeight INCHES sheet rendered at 96px/in inside fs-zoom-pan
1535
+ // (wheel zoom, background drag pan, toolbar buttons).
1536
+ //
1537
+ // Freeform layout: components are absolutely positioned; dragging gets smart
1538
+ // alignment guides (edges + centers of siblings and the page) with snapping.
1539
+ // Flow layout: components flow into rows (flowWidth % of the content width),
1540
+ // drag reorders, east/south handles resize width%/height.
1541
+ class ReportCanvasComponent {
1542
+ report;
1543
+ editMode = false;
1544
+ // A component asked for its settings dialog — the page-level host opens it
1545
+ // (it owns MatDialog + the post-save reload).
1546
+ componentSettings = new EventEmitter();
1547
+ // The report's structure changed here (a page was added) — the host re-fetches
1548
+ // it (it owns the assembled report + filter state).
1549
+ reportChanged = new EventEmitter();
1550
+ // The viewer dismissed edit mode from the on-canvas badge — the host owns the
1551
+ // editMode flag, so it flips it off (mirrors the menu's "Done editing layout").
1552
+ editDone = new EventEmitter();
1553
+ zoomPan;
1554
+ _viewport;
1555
+ _guides;
1556
+ PX = PX_PER_INCH;
1557
+ flowPadding = FLOW_PADDING;
1558
+ activePageIndex = 0;
1559
+ zoom = 1;
1560
+ groups = new Map();
1561
+ selectedComponentId = null;
1562
+ // The snapper handed to every component (stable identity for OnPush).
1563
+ snapper = {
1564
+ snapMove: (component, x, y) => this._snapMove(component, x, y),
1565
+ snapResize: (component, geometry, handle) => this._snapResize(component, geometry, handle),
1566
+ clear: () => this._clearGuides(),
1567
+ };
1568
+ _reportData = inject(ReportData);
1569
+ _cdRef = inject(ChangeDetectorRef);
1570
+ _destroyRef = inject(DestroyRef);
1571
+ _fitted = false;
1572
+ _previousPageCount = 0;
1573
+ ngOnChanges() {
1574
+ this.groups = new Map((this.report?.filterGroups ?? []).map((group) => [group.id, group]));
1575
+ const pageCount = this.report?.pages?.length ?? 0;
1576
+ // A page was just added (the host re-fetched the report) — land on it.
1577
+ if (pageCount > this._previousPageCount && this._previousPageCount > 0) {
1578
+ this.activePageIndex = pageCount - 1;
1579
+ }
1580
+ this._previousPageCount = pageCount;
1581
+ if (this.activePageIndex >= pageCount) {
1582
+ this.activePageIndex = 0;
1583
+ }
1584
+ }
1585
+ // Report heading size in px (config is points; the canvas renders at
1586
+ // 96px/in, so points × 96/72). Inherited by component titles via a CSS var.
1587
+ get headingSizePx() {
1588
+ const points = this.report?.config?.styles?.heading?.size ?? DEFAULT_HEADING_SIZE;
1589
+ return points * (PX_PER_INCH / 72);
1590
+ }
1591
+ ngAfterViewInit() {
1592
+ // Fit the page to the viewport once on first render; after that the
1593
+ // viewer's zoom choice is respected across re-renders.
1594
+ setTimeout(() => this.zoomFit());
1595
+ }
1596
+ get isFlow() {
1597
+ return this.report?.layout === 'flow';
1598
+ }
1599
+ get activePage() {
1600
+ return this.report?.pages?.[this.activePageIndex] ?? null;
1601
+ }
1602
+ // Flow render order (the assembler already sorts, but a local reorder
1603
+ // mid-session must hold without a re-fetch).
1604
+ get flowComponents() {
1605
+ return [...(this.activePage?.components ?? [])].sort((a, b) => a.order - b.order);
1606
+ }
1607
+ selectPage(index) {
1608
+ this.activePageIndex = index;
1609
+ this.selectedComponentId = null;
1610
+ }
1611
+ // Append a blank page; the host re-fetches the report and ngOnChanges lands
1612
+ // the view on the new (last) page.
1613
+ addPage() {
1614
+ if (!this.report) {
1615
+ return;
1616
+ }
1617
+ this._reportData.addPage(this.report.id)
1618
+ .pipe(takeUntilDestroyed(this._destroyRef))
1619
+ .subscribe(() => this.reportChanged.emit());
1620
+ }
1621
+ pageLabel(page, index) {
1622
+ return page.name || `Page ${index + 1}`;
1623
+ }
1624
+ selectComponent(componentId) {
1625
+ this.selectedComponentId = componentId;
1626
+ this._cdRef.markForCheck();
1627
+ }
1628
+ onCanvasPointerDown() {
1629
+ // Background click (pan start) deselects.
1630
+ if (this.selectedComponentId !== null) {
1631
+ this.selectComponent(null);
1632
+ }
1633
+ }
1634
+ // ----- zoom toolbar ---------------------------------------------------------
1635
+ onZoomed(scale) {
1636
+ this.zoom = scale;
1637
+ this._cdRef.markForCheck();
1638
+ }
1639
+ zoomIn() {
1640
+ this.zoomPan?.zoomIn();
1641
+ }
1642
+ zoomOut() {
1643
+ this.zoomPan?.zoomOut();
1644
+ }
1645
+ zoomFit() {
1646
+ const viewport = this._viewport?.nativeElement;
1647
+ if (!viewport || !this.report?.pageWidth || !this.zoomPan) {
1648
+ return;
1649
+ }
1650
+ const fit = Math.min((viewport.clientWidth - 32) / (this.report.pageWidth * PX_PER_INCH), (viewport.clientHeight - 32) / (this.report.pageHeight * PX_PER_INCH));
1651
+ this.zoomPan.zoom(Math.min(1.5, Math.max(0.1, fit)));
1652
+ this.zoomPan.move(16, 16);
1653
+ this._fitted = true;
1654
+ }
1655
+ zoomActual() {
1656
+ this.zoomPan?.zoom(1);
1657
+ }
1658
+ // ----- persistence ----------------------------------------------------------
1659
+ // A freeform drag/resize or a flow width resize landed — one PUT per gesture.
1660
+ onPositionChanged(position) {
1661
+ this._clearGuides();
1662
+ this._reportData.savePositions(this.report.id, [position])
1663
+ .pipe(takeUntilDestroyed(this._destroyRef))
1664
+ .subscribe();
1665
+ }
1666
+ // A flow drag dropped: reorder the page's components around the moved one
1667
+ // and persist every order in one PUT.
1668
+ onFlowReorder(event) {
1669
+ const page = this.activePage;
1670
+ if (!page) {
1671
+ return;
1672
+ }
1673
+ const ordered = this.flowComponents.filter((component) => component.id !== event.componentId);
1674
+ const moved = page.components.find((component) => component.id === event.componentId);
1675
+ if (!moved) {
1676
+ return;
1677
+ }
1678
+ const index = this._flowDropIndex(ordered, event.clientX, event.clientY);
1679
+ ordered.splice(index, 0, moved);
1680
+ const positions = ordered.map((component, order) => {
1681
+ component.order = order;
1682
+ return { componentId: component.id, order };
1683
+ });
1684
+ this._cdRef.markForCheck();
1685
+ this._reportData.savePositions(this.report.id, positions)
1686
+ .pipe(takeUntilDestroyed(this._destroyRef))
1687
+ .subscribe();
1688
+ }
1689
+ // Where the pointer landed among the flowed components: before the first
1690
+ // component whose center the pointer is above/left of.
1691
+ _flowDropIndex(ordered, clientX, clientY) {
1692
+ const hosts = Array.from(this._viewport.nativeElement.querySelectorAll('app-report-component')).filter((host) => !host.classList.contains('flow-dragging'));
1693
+ for (let index = 0; index < hosts.length && index < ordered.length; index++) {
1694
+ const rect = hosts[index].getBoundingClientRect();
1695
+ if (clientY < rect.top + rect.height / 2
1696
+ || (clientY < rect.bottom && clientX < rect.left + rect.width / 2)) {
1697
+ return index;
1698
+ }
1699
+ }
1700
+ return ordered.length;
1701
+ }
1702
+ // ----- smart guides ---------------------------------------------------------
1703
+ // Pixel feel of the snap, converted to inches at the current zoom.
1704
+ get _snapThreshold() {
1705
+ return 8 / (PX_PER_INCH * Math.max(0.1, this.zoom));
1706
+ }
1707
+ _snapMove(component, x, y) {
1708
+ const { vertical, horizontal } = this._candidates(component);
1709
+ const guides = [];
1710
+ const snappedX = this._snapAxis([
1711
+ { offset: 0, value: x },
1712
+ { offset: component.w / 2, value: x + component.w / 2 },
1713
+ { offset: component.w, value: x + component.w },
1714
+ ], vertical);
1715
+ if (snappedX !== null) {
1716
+ x = snappedX.base;
1717
+ guides.push({ orientation: 'v', position: snappedX.guide });
1718
+ }
1719
+ const snappedY = this._snapAxis([
1720
+ { offset: 0, value: y },
1721
+ { offset: component.h / 2, value: y + component.h / 2 },
1722
+ { offset: component.h, value: y + component.h },
1723
+ ], horizontal);
1724
+ if (snappedY !== null) {
1725
+ y = snappedY.base;
1726
+ guides.push({ orientation: 'h', position: snappedY.guide });
1727
+ }
1728
+ this._renderGuides(guides);
1729
+ return { x, y };
1730
+ }
1731
+ _snapResize(component, geometry, handle) {
1732
+ const { vertical, horizontal } = this._candidates(component);
1733
+ const guides = [];
1734
+ const result = { ...geometry };
1735
+ // Only the edges the handle moves participate in snapping.
1736
+ if (handle.includes('e')) {
1737
+ const snapped = this._nearest(geometry.x + geometry.w, vertical);
1738
+ if (snapped !== null) {
1739
+ result.w = snapped - geometry.x;
1740
+ guides.push({ orientation: 'v', position: snapped });
1741
+ }
1742
+ }
1743
+ if (handle.includes('w')) {
1744
+ const snapped = this._nearest(geometry.x, vertical);
1745
+ if (snapped !== null) {
1746
+ result.w = geometry.x + geometry.w - snapped;
1747
+ result.x = snapped;
1748
+ guides.push({ orientation: 'v', position: snapped });
1749
+ }
1750
+ }
1751
+ if (handle.includes('s')) {
1752
+ const snapped = this._nearest(geometry.y + geometry.h, horizontal);
1753
+ if (snapped !== null) {
1754
+ result.h = snapped - geometry.y;
1755
+ guides.push({ orientation: 'h', position: snapped });
1756
+ }
1757
+ }
1758
+ if (handle.includes('n')) {
1759
+ const snapped = this._nearest(geometry.y, horizontal);
1760
+ if (snapped !== null) {
1761
+ result.h = geometry.y + geometry.h - snapped;
1762
+ result.y = snapped;
1763
+ guides.push({ orientation: 'h', position: snapped });
1764
+ }
1765
+ }
1766
+ this._renderGuides(guides);
1767
+ return result;
1768
+ }
1769
+ // Alignment candidates: the page's edges + center, and every sibling's
1770
+ // edges + centers.
1771
+ _candidates(moving) {
1772
+ const vertical = [0, this.report.pageWidth / 2, this.report.pageWidth];
1773
+ const horizontal = [0, this.report.pageHeight / 2, this.report.pageHeight];
1774
+ for (const component of this.activePage?.components ?? []) {
1775
+ if (component.id === moving.id) {
1776
+ continue;
1777
+ }
1778
+ vertical.push(component.x, component.x + component.w / 2, component.x + component.w);
1779
+ horizontal.push(component.y, component.y + component.h / 2, component.y + component.h);
1780
+ }
1781
+ return { vertical, horizontal };
1782
+ }
1783
+ // Snap one axis: of the moving box's three lines (edge/center/edge), find
1784
+ // the closest candidate within threshold and return the adjusted base
1785
+ // coordinate plus the guide to draw.
1786
+ _snapAxis(lines, candidates) {
1787
+ let best = null;
1788
+ for (const line of lines) {
1789
+ for (const candidate of candidates) {
1790
+ const distance = Math.abs(candidate - line.value);
1791
+ if (distance <= this._snapThreshold && (!best || distance < best.distance)) {
1792
+ best = { base: candidate - line.offset, guide: candidate, distance };
1793
+ }
1794
+ }
1795
+ }
1796
+ return best ? { base: best.base, guide: best.guide } : null;
1797
+ }
1798
+ _nearest(value, candidates) {
1799
+ let best = null;
1800
+ let bestDistance = this._snapThreshold;
1801
+ for (const candidate of candidates) {
1802
+ const distance = Math.abs(candidate - value);
1803
+ if (distance <= bestDistance) {
1804
+ best = candidate;
1805
+ bestDistance = distance;
1806
+ }
1807
+ }
1808
+ return best;
1809
+ }
1810
+ // The guides overlay is drawn imperatively — the drag loop runs outside
1811
+ // Angular and repaints on every pointermove.
1812
+ _renderGuides(guides) {
1813
+ const host = this._guides?.nativeElement;
1814
+ if (!host) {
1815
+ return;
1816
+ }
1817
+ host.replaceChildren(...guides.map((guide) => {
1818
+ const line = document.createElement('div');
1819
+ line.className = `guide guide-${guide.orientation}`;
1820
+ if (guide.orientation === 'v') {
1821
+ line.style.left = `${guide.position * PX_PER_INCH}px`;
1822
+ }
1823
+ else {
1824
+ line.style.top = `${guide.position * PX_PER_INCH}px`;
1825
+ }
1826
+ return line;
1827
+ }));
1828
+ }
1829
+ _clearGuides() {
1830
+ this._guides?.nativeElement?.replaceChildren();
1831
+ }
1832
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportCanvasComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1833
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportCanvasComponent, isStandalone: true, selector: "app-report-canvas", inputs: { report: "report", editMode: "editMode" }, outputs: { componentSettings: "componentSettings", reportChanged: "reportChanged", editDone: "editDone" }, viewQueries: [{ propertyName: "zoomPan", first: true, predicate: FsZoomPanComponent, descendants: true }, { propertyName: "_viewport", first: true, predicate: ["viewport"], descendants: true, static: true }, { propertyName: "_guides", first: true, predicate: ["guides"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"canvas\">\n <div\n #viewport\n class=\"page-viewport\"\n (pointerdown)=\"onCanvasPointerDown()\">\n <fs-zoom-pan\n [zoomMin]=\"0.1\"\n [zoomMax]=\"3\"\n (zoomed)=\"onZoomed($event)\">\n @if (activePage; as page) {\n <div\n class=\"page\"\n [class.flow]=\"isFlow\"\n [class.editing]=\"editMode\"\n [style.width.px]=\"report.pageWidth * PX\"\n [style.height.px]=\"report.pageHeight * PX\"\n [style.padding.px]=\"isFlow ? flowPadding * PX : 0\"\n [style.--canvas-zoom]=\"zoom\"\n [style.--report-heading-size.px]=\"headingSizePx\">\n @if (isFlow) {\n @for (component of flowComponents; track component.id) {\n <app-report-component\n class=\"flow-item\"\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"editMode\"\n [layout]=\"'flow'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [selected]=\"component.id === selectedComponentId\"\n [style.width.%]=\"component.flowWidth\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (flowReorder)=\"onFlowReorder($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n } @else {\n @for (component of page.components; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"editMode\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [selected]=\"component.id === selectedComponentId\"\n [style.left.px]=\"component.x * PX\"\n [style.top.px]=\"component.y * PX\"\n [style.width.px]=\"component.w * PX\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n }\n\n <div\n #guides\n class=\"guides\">\n </div>\n\n @if (!page.components.length) {\n <div class=\"page-empty\">\n Ask the assistant to add a chart, list or KPI to this page.\n </div>\n }\n </div>\n }\n </fs-zoom-pan>\n\n @if (editMode) {\n <button\n mat-flat-button\n type=\"button\"\n color=\"primary\"\n class=\"edit-done\"\n matTooltip=\"Exit layout editing\"\n (click)=\"editDone.emit()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n }\n </div>\n\n <div class=\"canvas-toolbar\">\n <div class=\"page-tabs\">\n @if ((report?.pages?.length ?? 0) > 1) {\n @for (page of report.pages; track page.id; let index = $index) {\n <button\n type=\"button\"\n class=\"page-tab\"\n [class.active]=\"index === activePageIndex\"\n (click)=\"selectPage(index)\">\n {{ pageLabel(page, index) }}\n </button>\n }\n }\n <button\n type=\"button\"\n class=\"page-tab add-page\"\n matTooltip=\"Add page\"\n (click)=\"addPage()\">\n <mat-icon>add</mat-icon>\n Page\n </button>\n </div>\n\n <div class=\"zoom-controls\">\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom out\"\n (click)=\"zoomOut()\">\n <mat-icon>remove</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-label\"\n matTooltip=\"Zoom to 100%\"\n (click)=\"zoomActual()\">\n {{ (zoom * 100).toFixed(0) }}%\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom in\"\n (click)=\"zoomIn()\">\n <mat-icon>add</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Fit page\"\n (click)=\"zoomFit()\">\n <mat-icon>fit_screen</mat-icon>\n </button>\n </div>\n </div>\n</div>\n", styles: [":host{display:flex;flex-direction:column;min-width:0;min-height:0;flex:1;margin-top:10px}.canvas{display:flex;flex-direction:column;gap:8px;flex:1;min-height:0}.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px}.canvas-toolbar .page-tabs{display:flex;gap:4px;flex-wrap:wrap}.canvas-toolbar .page-tabs .page-tab{border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 14px;font-size:12px;color:#52606d;cursor:pointer}.canvas-toolbar .page-tabs .page-tab.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.canvas-toolbar .page-tabs .page-tab.add-page{display:inline-flex;align-items:center;gap:2px;padding-left:8px;border-style:dashed;color:#52606d}.canvas-toolbar .page-tabs .page-tab.add-page mat-icon{font-size:16px;width:16px;height:16px}.canvas-toolbar .zoom-controls{display:flex;align-items:center;gap:2px;margin-left:auto;border:1px solid #e4e7eb;border-radius:18px;background:#fff;padding:2px 6px}.canvas-toolbar .zoom-controls .zoom-button{display:flex;align-items:center;justify-content:center;border:none;border-radius:50%;background:transparent;color:#52606d;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-button:hover{background:#f0f4f8}.canvas-toolbar .zoom-controls .zoom-button mat-icon{font-size:18px;width:18px;height:18px}.canvas-toolbar .zoom-controls .zoom-label{border:none;background:transparent;font-size:12px;color:#52606d;min-width:44px;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-label:hover{color:#1f2933}.page-viewport{position:relative;flex:1;min-height:420px;border:1px solid #e4e7eb;border-radius:8px;background:#eef1f5;overflow:hidden}.page-viewport fs-zoom-pan{width:100%;height:100%}.edit-done{position:absolute;top:12px;right:12px;z-index:40;border-radius:999px;box-shadow:0 2px 10px #0f172a2e}.page{position:relative;background:#fff;border-radius:2px;box-shadow:0 2px 12px #0f172a1f,0 0 0 1px #0f172a0a;box-sizing:border-box}.page.flow{display:flex;flex-wrap:wrap;align-content:flex-start;align-items:flex-start}.page.editing{background-image:radial-gradient(circle,#d8dee6 1px,transparent 1px);background-size:24px 24px}.guides{position:absolute;inset:0;pointer-events:none;z-index:30}.guides ::ng-deep .guide{position:absolute;background:#2196f3}.guides ::ng-deep .guide.guide-v{top:0;bottom:0;width:calc(1.25px / var(--canvas-zoom, 1))}.guides ::ng-deep .guide.guide-h{left:0;right:0;height:calc(1.25px / var(--canvas-zoom, 1))}.page-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#9aa5b1;font-size:14px;pointer-events:none}\n"], dependencies: [{ kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsZoomPanModule }, { kind: "component", type: i1$2.FsZoomPanComponent, selector: "fs-zoom-pan", inputs: ["zoomMax", "zoomMin", "zoomScale", "zoomDefault", "zoomFactor", "top", "left"], outputs: ["moved", "zoomed"] }, { kind: "component", type: ReportComponentComponent, selector: "app-report-component", inputs: ["reportId", "component", "groups", "editMode", "layout", "zoom", "snapper", "selected"], outputs: ["selectComponent", "positionChanged", "flowReorder", "openSettings"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1834
+ }
1835
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportCanvasComponent, decorators: [{
1836
+ type: Component,
1837
+ args: [{ selector: 'app-report-canvas', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
1838
+ MatButton,
1839
+ MatIcon,
1840
+ MatTooltip,
1841
+ FsZoomPanModule,
1842
+ ReportComponentComponent,
1843
+ ], template: "<div class=\"canvas\">\n <div\n #viewport\n class=\"page-viewport\"\n (pointerdown)=\"onCanvasPointerDown()\">\n <fs-zoom-pan\n [zoomMin]=\"0.1\"\n [zoomMax]=\"3\"\n (zoomed)=\"onZoomed($event)\">\n @if (activePage; as page) {\n <div\n class=\"page\"\n [class.flow]=\"isFlow\"\n [class.editing]=\"editMode\"\n [style.width.px]=\"report.pageWidth * PX\"\n [style.height.px]=\"report.pageHeight * PX\"\n [style.padding.px]=\"isFlow ? flowPadding * PX : 0\"\n [style.--canvas-zoom]=\"zoom\"\n [style.--report-heading-size.px]=\"headingSizePx\">\n @if (isFlow) {\n @for (component of flowComponents; track component.id) {\n <app-report-component\n class=\"flow-item\"\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"editMode\"\n [layout]=\"'flow'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [selected]=\"component.id === selectedComponentId\"\n [style.width.%]=\"component.flowWidth\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (flowReorder)=\"onFlowReorder($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n } @else {\n @for (component of page.components; track component.id) {\n <app-report-component\n [reportId]=\"report.id\"\n [component]=\"component\"\n [groups]=\"groups\"\n [editMode]=\"editMode\"\n [layout]=\"'freeform'\"\n [zoom]=\"zoom\"\n [snapper]=\"snapper\"\n [selected]=\"component.id === selectedComponentId\"\n [style.left.px]=\"component.x * PX\"\n [style.top.px]=\"component.y * PX\"\n [style.width.px]=\"component.w * PX\"\n [style.height.px]=\"component.autoHeight ? null : component.h * PX\"\n (selectComponent)=\"selectComponent($event)\"\n (positionChanged)=\"onPositionChanged($event)\"\n (openSettings)=\"componentSettings.emit($event)\">\n </app-report-component>\n }\n }\n\n <div\n #guides\n class=\"guides\">\n </div>\n\n @if (!page.components.length) {\n <div class=\"page-empty\">\n Ask the assistant to add a chart, list or KPI to this page.\n </div>\n }\n </div>\n }\n </fs-zoom-pan>\n\n @if (editMode) {\n <button\n mat-flat-button\n type=\"button\"\n color=\"primary\"\n class=\"edit-done\"\n matTooltip=\"Exit layout editing\"\n (click)=\"editDone.emit()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n }\n </div>\n\n <div class=\"canvas-toolbar\">\n <div class=\"page-tabs\">\n @if ((report?.pages?.length ?? 0) > 1) {\n @for (page of report.pages; track page.id; let index = $index) {\n <button\n type=\"button\"\n class=\"page-tab\"\n [class.active]=\"index === activePageIndex\"\n (click)=\"selectPage(index)\">\n {{ pageLabel(page, index) }}\n </button>\n }\n }\n <button\n type=\"button\"\n class=\"page-tab add-page\"\n matTooltip=\"Add page\"\n (click)=\"addPage()\">\n <mat-icon>add</mat-icon>\n Page\n </button>\n </div>\n\n <div class=\"zoom-controls\">\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom out\"\n (click)=\"zoomOut()\">\n <mat-icon>remove</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-label\"\n matTooltip=\"Zoom to 100%\"\n (click)=\"zoomActual()\">\n {{ (zoom * 100).toFixed(0) }}%\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Zoom in\"\n (click)=\"zoomIn()\">\n <mat-icon>add</mat-icon>\n </button>\n <button\n type=\"button\"\n class=\"zoom-button\"\n matTooltip=\"Fit page\"\n (click)=\"zoomFit()\">\n <mat-icon>fit_screen</mat-icon>\n </button>\n </div>\n </div>\n</div>\n", styles: [":host{display:flex;flex-direction:column;min-width:0;min-height:0;flex:1;margin-top:10px}.canvas{display:flex;flex-direction:column;gap:8px;flex:1;min-height:0}.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px}.canvas-toolbar .page-tabs{display:flex;gap:4px;flex-wrap:wrap}.canvas-toolbar .page-tabs .page-tab{border:1px solid #e4e7eb;border-radius:16px;background:#fff;padding:4px 14px;font-size:12px;color:#52606d;cursor:pointer}.canvas-toolbar .page-tabs .page-tab.active{background:var(--brand-primary-color, #3b82f6);border-color:var(--brand-primary-color, #3b82f6);color:#fff}.canvas-toolbar .page-tabs .page-tab.add-page{display:inline-flex;align-items:center;gap:2px;padding-left:8px;border-style:dashed;color:#52606d}.canvas-toolbar .page-tabs .page-tab.add-page mat-icon{font-size:16px;width:16px;height:16px}.canvas-toolbar .zoom-controls{display:flex;align-items:center;gap:2px;margin-left:auto;border:1px solid #e4e7eb;border-radius:18px;background:#fff;padding:2px 6px}.canvas-toolbar .zoom-controls .zoom-button{display:flex;align-items:center;justify-content:center;border:none;border-radius:50%;background:transparent;color:#52606d;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-button:hover{background:#f0f4f8}.canvas-toolbar .zoom-controls .zoom-button mat-icon{font-size:18px;width:18px;height:18px}.canvas-toolbar .zoom-controls .zoom-label{border:none;background:transparent;font-size:12px;color:#52606d;min-width:44px;cursor:pointer}.canvas-toolbar .zoom-controls .zoom-label:hover{color:#1f2933}.page-viewport{position:relative;flex:1;min-height:420px;border:1px solid #e4e7eb;border-radius:8px;background:#eef1f5;overflow:hidden}.page-viewport fs-zoom-pan{width:100%;height:100%}.edit-done{position:absolute;top:12px;right:12px;z-index:40;border-radius:999px;box-shadow:0 2px 10px #0f172a2e}.page{position:relative;background:#fff;border-radius:2px;box-shadow:0 2px 12px #0f172a1f,0 0 0 1px #0f172a0a;box-sizing:border-box}.page.flow{display:flex;flex-wrap:wrap;align-content:flex-start;align-items:flex-start}.page.editing{background-image:radial-gradient(circle,#d8dee6 1px,transparent 1px);background-size:24px 24px}.guides{position:absolute;inset:0;pointer-events:none;z-index:30}.guides ::ng-deep .guide{position:absolute;background:#2196f3}.guides ::ng-deep .guide.guide-v{top:0;bottom:0;width:calc(1.25px / var(--canvas-zoom, 1))}.guides ::ng-deep .guide.guide-h{left:0;right:0;height:calc(1.25px / var(--canvas-zoom, 1))}.page-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#9aa5b1;font-size:14px;pointer-events:none}\n"] }]
1844
+ }], propDecorators: { report: [{
1845
+ type: Input
1846
+ }], editMode: [{
1847
+ type: Input
1848
+ }], componentSettings: [{
1849
+ type: Output
1850
+ }], reportChanged: [{
1851
+ type: Output
1852
+ }], editDone: [{
1853
+ type: Output
1854
+ }], zoomPan: [{
1855
+ type: ViewChild,
1856
+ args: [FsZoomPanComponent]
1857
+ }], _viewport: [{
1858
+ type: ViewChild,
1859
+ args: ['viewport', { static: true }]
1860
+ }], _guides: [{
1861
+ type: ViewChild,
1862
+ args: ['guides', { static: false }]
1863
+ }] } });
1864
+
1865
+ // Component settings, in tabs:
1866
+ // Settings — title and content padding. Position/size are tuned by dragging
1867
+ // on the canvas; height behaviour follows from the component type.
1868
+ // Filters — ONE listing of every filterable column on this component. Each
1869
+ // row carries two toggles: show in the report bar and/or show on this
1870
+ // component (both, either, or neither). The interface (date range vs value
1871
+ // picker) is inferred from the data, never chosen. Each toggle persists
1872
+ // immediately and refreshes the in-dialog list.
1873
+ // SQL / Config — read-only diagnostics.
1874
+ class ComponentSettingsComponent {
1875
+ component;
1876
+ report;
1877
+ selectedTab = 'settings';
1878
+ title = '';
1879
+ paddingTop = DEFAULT_COMPONENT_PADDING;
1880
+ paddingRight = DEFAULT_COMPONENT_PADDING;
1881
+ paddingBottom = DEFAULT_COMPONENT_PADDING;
1882
+ paddingLeft = DEFAULT_COMPONENT_PADDING;
1883
+ configJson = '';
1884
+ // ---- filter management state ----
1885
+ // One row per filterable column. The two booleans drive the toggles; we flip
1886
+ // them optimistically on click (so the UI never blinks while the request is
1887
+ // in flight) and reconcile from the server on reload.
1888
+ rows = [];
1889
+ columnsError = null;
1890
+ // The raw inputs the rows are built from.
1891
+ _filterableColumns = [];
1892
+ _filters = [];
1893
+ _groupsById = new Map();
1894
+ _typeLabels = {
1895
+ select: 'Pick values',
1896
+ dateRange: 'Date range',
1897
+ keyword: 'Search text',
1898
+ };
1899
+ // True once any filter mutation happened — the parent reloads the report so
1900
+ // the canvas + report bar reflect the change.
1901
+ _changed = false;
1902
+ _data = inject(MAT_DIALOG_DATA);
1903
+ _dialogRef = inject(MatDialogRef);
1904
+ _reportData = inject(ReportData);
1905
+ _message = inject(FsMessage);
1906
+ _cdRef = inject(ChangeDetectorRef);
1907
+ ngOnInit() {
1908
+ this.report = this._data.report;
1909
+ this.component = this._data.component;
1910
+ this.title = this.component.title ?? '';
1911
+ const padding = this.component.config?.padding;
1912
+ this.paddingTop = padding?.top ?? DEFAULT_COMPONENT_PADDING;
1913
+ this.paddingRight = padding?.right ?? DEFAULT_COMPONENT_PADDING;
1914
+ this.paddingBottom = padding?.bottom ?? DEFAULT_COMPONENT_PADDING;
1915
+ this.paddingLeft = padding?.left ?? DEFAULT_COMPONENT_PADDING;
1916
+ this.configJson = JSON.stringify(this.component.config ?? {}, null, 2);
1917
+ this._seedFilters(this.report, this.component.id);
1918
+ this._loadColumns();
1919
+ }
1920
+ get hasChanges() {
1921
+ return this._changed;
1922
+ }
1923
+ // Human-friendly name for the inferred interface shown beside each column.
1924
+ typeLabel(type) {
1925
+ return this._typeLabels[type] ?? type;
1926
+ }
1927
+ save = () => {
1928
+ const settings = {
1929
+ title: this.title,
1930
+ padding: {
1931
+ top: this.paddingTop,
1932
+ right: this.paddingRight,
1933
+ bottom: this.paddingBottom,
1934
+ left: this.paddingLeft,
1935
+ },
1936
+ };
1937
+ return this._reportData.updateComponent(this.report.id, this.component.id, settings)
1938
+ .pipe(tap(() => {
1939
+ this._message.success('Component saved');
1940
+ this._dialogRef.close(true);
1941
+ }));
1942
+ };
1943
+ // Flip one exposure (report bar or this component) for a row. We update the
1944
+ // row's boolean first — so the toggle stays put with no blink — then persist
1945
+ // and reconcile on reload. Neither on → no filter (removed); either on → the
1946
+ // filter exists at the combined level. The interface type is inferred
1947
+ // server-side.
1948
+ setExposure(row, target, on) {
1949
+ row[target] = on;
1950
+ const existing = this._filters.find((filter) => filter.filterColumn === row.column);
1951
+ if (!row.report && !row.component) {
1952
+ if (existing) {
1953
+ this._run(this._reportData.deleteFilter(this.report.id, existing.id));
1954
+ }
1955
+ return;
1956
+ }
1957
+ const level = row.report && row.component
1958
+ ? 'both'
1959
+ : (row.report ? 'report' : 'component');
1960
+ if (existing) {
1961
+ this._run(this._reportData.setFilterGroupLevel(this.report.id, existing.filterGroupId, level));
1962
+ }
1963
+ else {
1964
+ this._run(this._reportData.addFilter(this.report.id, this.component.id, {
1965
+ filterColumn: row.column,
1966
+ label: row.label,
1967
+ level,
1968
+ }));
1969
+ }
1970
+ }
1971
+ // Run a filter mutation, then reload to reconcile the optimistic state.
1972
+ _run(request) {
1973
+ request.subscribe({
1974
+ next: () => this._afterMutation(),
1975
+ error: (error) => this._mutationError(error),
1976
+ });
1977
+ }
1978
+ // Build the row view-model from the loaded columns + filters/groups. Each
1979
+ // row's toggles reflect where its filter group is currently exposed.
1980
+ _rebuildRows() {
1981
+ this.rows = this._filterableColumns.map((column) => {
1982
+ const filter = this._filters.find((candidate) => candidate.filterColumn === column.column);
1983
+ const level = filter ? this._groupsById.get(filter.filterGroupId)?.level : undefined;
1984
+ return {
1985
+ column: column.column,
1986
+ type: column.type,
1987
+ label: filter?.label
1988
+ || this._groupsById.get(filter?.filterGroupId ?? -1)?.label
1989
+ || this._humanize(column.column),
1990
+ report: level === 'report' || level === 'both',
1991
+ component: level === 'component' || level === 'both',
1992
+ };
1993
+ });
1994
+ }
1995
+ // After any filter mutation: re-fetch the report so the dialog's filter list
1996
+ // (and the parent canvas, on close) reflect the new groups/levels.
1997
+ _afterMutation() {
1998
+ this._changed = true;
1999
+ this._reportData.get(this.report.id)
2000
+ .subscribe((report) => {
2001
+ this.report = report;
2002
+ this._seedFilters(report, this.component.id);
2003
+ this._cdRef.markForCheck();
2004
+ });
2005
+ }
2006
+ _mutationError(error) {
2007
+ this._message.error(error?.error?.message ?? error?.message ?? 'Filter operation failed');
2008
+ // Re-seed from the server so the optimistic toggle snaps back to truth.
2009
+ this._afterMutation();
2010
+ }
2011
+ _seedFilters(report, componentId) {
2012
+ this._groupsById = new Map((report.filterGroups ?? []).map((group) => [group.id, group]));
2013
+ const component = report.pages
2014
+ .flatMap((page) => page.components)
2015
+ .find((candidate) => candidate.id === componentId);
2016
+ this._filters = component?.filters ?? [];
2017
+ this._rebuildRows();
2018
+ }
2019
+ _loadColumns() {
2020
+ this._reportData.componentColumns(this.report.id, this.component.id)
2021
+ .subscribe({
2022
+ next: (columns) => {
2023
+ this._filterableColumns = columns ?? [];
2024
+ this._rebuildRows();
2025
+ this._cdRef.markForCheck();
2026
+ },
2027
+ error: (error) => {
2028
+ this.columnsError = error?.error?.message ?? 'Could not read the component columns';
2029
+ this._cdRef.markForCheck();
2030
+ },
2031
+ });
2032
+ }
2033
+ _humanize(column) {
2034
+ return column
2035
+ .replace(/[_\-]+/g, ' ')
2036
+ .replace(/\s+/g, ' ')
2037
+ .trim()
2038
+ .replace(/\b\w/g, (character) => character.toUpperCase());
2039
+ }
2040
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentSettingsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2041
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentSettingsComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n {{ component.title || 'Component' }}\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Title\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"title\"\n name=\"title\">\n </mat-form-field>\n <div class=\"subheading-2\">\n Padding\n </div>\n <div class=\"geometry-row\">\n <mat-form-field>\n <mat-label>\n Top\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"0.05\"\n min=\"0\"\n [(ngModel)]=\"paddingTop\"\n name=\"paddingTop\">\n <span matTextSuffix>\n in\n </span>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Right\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"0.05\"\n min=\"0\"\n [(ngModel)]=\"paddingRight\"\n name=\"paddingRight\">\n <span matTextSuffix>\n in\n </span>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Bottom\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"0.05\"\n min=\"0\"\n [(ngModel)]=\"paddingBottom\"\n name=\"paddingBottom\">\n <span matTextSuffix>\n in\n </span>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Left\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"0.05\"\n min=\"0\"\n [(ngModel)]=\"paddingLeft\"\n name=\"paddingLeft\">\n <span matTextSuffix>\n in\n </span>\n </mat-form-field>\n </div>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"tab-body\">\n @if (columnsError) {\n <div class=\"filters-empty error\">\n {{ columnsError }}\n </div>\n } @else if (rows.length) {\n <!-- Every filterable column. Toggle where its control shows: in\n the report bar, on this component, both, or neither. The\n interface (date range vs value picker) follows the data. -->\n <div class=\"filter-list\">\n <div class=\"filter-head\">\n <span class=\"filter-head-spacer\"></span>\n <span class=\"small\">\n Report bar\n </span>\n <span class=\"small\">\n This component\n </span>\n </div>\n @for (row of rows; track row.column) {\n <div class=\"filter-card\">\n <div class=\"filter-card-main\">\n <div class=\"filter-card-title\">\n {{ row.label }}\n </div>\n <div class=\"filter-card-meta\">\n {{ typeLabel(row.type) }}\n </div>\n </div>\n <mat-slide-toggle\n class=\"filter-toggle\"\n [ngModel]=\"row.report\"\n (ngModelChange)=\"setExposure(row, 'report', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show in the report bar\">\n </mat-slide-toggle>\n <mat-slide-toggle\n class=\"filter-toggle\"\n [ngModel]=\"row.component\"\n (ngModelChange)=\"setExposure(row, 'component', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show on this component\">\n </mat-slide-toggle>\n </div>\n }\n </div>\n } @else {\n <div class=\"filters-empty\">\n This component has no filterable columns yet \u2014 give it some SQL first.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"SQL\"\n name=\"sql\">\n <div class=\"tab-body\">\n <pre class=\"code-block\">\n {{ component.sql }}\n </pre>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Config\"\n name=\"config\">\n <div class=\"tab-body\">\n <pre class=\"code-block\">\n {{ configJson }}\n </pre>\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <fs-form-dialog-actions\n [save]=\"selectedTab === 'settings'\"\n [done]=\"selectedTab !== 'settings'\">\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}.geometry-row{display:flex;gap:8px}.geometry-row mat-form-field{flex:1}.filter-list{display:flex;flex-direction:column;gap:8px}.filter-head{display:flex;align-items:center;gap:8px;padding:0 10px 2px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;color:#9aa5b1}.filter-head .filter-head-spacer{flex:1}.filter-head .filter-head-toggle{width:110px;flex:0 0 auto;text-align:center}.filter-card{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid #e4e7eb;border-radius:8px;background:#fff}.filter-card .filter-card-main{flex:1;min-width:0}.filter-card .filter-card-main .filter-card-title{font-weight:600;font-size:13px;color:#1f2933;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.filter-card .filter-card-main .filter-card-meta{font-size:12px;color:#7b8794}.filter-card .filter-card-main .filter-card-meta code{background:#f0f4f8;border-radius:3px;padding:0 4px}.filter-card .filter-toggle{width:110px;flex:0 0 auto;display:flex;justify-content:center}.filters-empty{font-size:13px;color:#7b8794;padding:8px 0 4px}.filters-empty.error{color:#e15759}.code-block{margin:0;padding:10px 12px;background:#1f2933;color:#e4e7eb;border-radius:6px;font-family:JetBrains Mono,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow:auto}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$3.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$3.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$3.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1$3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$3.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$1.FsFormDirective, selector: "[fsForm]", inputs: ["wrapperSelector", "messageSelector", "hintSelector", "labelSelector", "autocomplete", "shortcuts", "confirm", "confirmDialog", "confirmDrawer", "confirmBrowser", "dirtySubmitButton", "submit", "successDelay", "errorDelay", "deactivationGuard"], outputs: ["fsForm", "invalid", "valid", "submitted", "reseted", "cleared"], exportAs: ["fsForm"] }, { kind: "component", type: i2$1.FsFormDialogActionsComponent, selector: "fs-form-dialog-actions", inputs: ["save", "create", "close", "done", "closeData", "name"] }, { kind: "directive", type: i2$1.FsFormNoFsValidatorsDirective, selector: "[ngModel]:not([required]):not([fsFormRequired]):not([fsFormCompare]):not([fsFormDateRange]):not([fsFormEmail]):not([fsFormEmails]):not([fsFormFunction]):not([fsFormGreater]):not([fsFormGreaterEqual]):not([fsFormInteger]):not([fsFormLesser]):not([fsFormMax]):not([fsFormMaxLength]):not([fsFormMin]):not([fsFormMinLength]):not([fsFormNumeric]):not([fsFormPattern]):not([fsFormPhone]):not([fsFormUrl]):not([validate])" }, { kind: "ngmodule", type: FsDialogModule }, { kind: "component", type: i3.FsDialogComponent, selector: "fs-dialog", inputs: ["mobileMode", "mobileButtonPlacement", "mobileWidth", "mode", "buttonLayout"] }, { kind: "ngmodule", type: FsLabelModule }, { kind: "ngmodule", type: FsTabsModule }, { kind: "directive", type: i4.FsTabsHeaderTabGroupDirective, selector: "mat-tab-group, matTabGroup, [matTabGroup]", inputs: ["orientation", "selected", "selectedData"], outputs: ["selectedChange", "selectedDataChange"], exportAs: ["fsTabsHeaderTabGroup"] }, { kind: "directive", type: i4.FsTabsTabDirective, selector: "mat-tab,matTab", inputs: ["name", "data"], exportAs: ["fsTabsTab"] }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i5.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass"], exportAs: ["matTab"] }, { kind: "component", type: i5.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "directive", type: MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "directive", type: MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly"], exportAs: ["matInput"] }, { kind: "component", type: MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon", "disabledInteractive"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2042
+ }
2043
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentSettingsComponent, decorators: [{
2044
+ type: Component,
2045
+ args: [{ changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
2046
+ FormsModule,
2047
+ FsFormModule,
2048
+ FsDialogModule,
2049
+ FsLabelModule,
2050
+ FsTabsModule,
2051
+ MatTabsModule,
2052
+ MatDialogTitle,
2053
+ MatDialogContent,
2054
+ MatDialogActions,
2055
+ MatFormField,
2056
+ MatLabel,
2057
+ MatSuffix,
2058
+ MatInput,
2059
+ MatSlideToggle,
2060
+ MatTooltip,
2061
+ ], template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n {{ component.title || 'Component' }}\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Title\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"title\"\n name=\"title\">\n </mat-form-field>\n <div class=\"subheading-2\">\n Padding\n </div>\n <div class=\"geometry-row\">\n <mat-form-field>\n <mat-label>\n Top\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"0.05\"\n min=\"0\"\n [(ngModel)]=\"paddingTop\"\n name=\"paddingTop\">\n <span matTextSuffix>\n in\n </span>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Right\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"0.05\"\n min=\"0\"\n [(ngModel)]=\"paddingRight\"\n name=\"paddingRight\">\n <span matTextSuffix>\n in\n </span>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Bottom\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"0.05\"\n min=\"0\"\n [(ngModel)]=\"paddingBottom\"\n name=\"paddingBottom\">\n <span matTextSuffix>\n in\n </span>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Left\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"0.05\"\n min=\"0\"\n [(ngModel)]=\"paddingLeft\"\n name=\"paddingLeft\">\n <span matTextSuffix>\n in\n </span>\n </mat-form-field>\n </div>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Filters\"\n name=\"filters\">\n <div class=\"tab-body\">\n @if (columnsError) {\n <div class=\"filters-empty error\">\n {{ columnsError }}\n </div>\n } @else if (rows.length) {\n <!-- Every filterable column. Toggle where its control shows: in\n the report bar, on this component, both, or neither. The\n interface (date range vs value picker) follows the data. -->\n <div class=\"filter-list\">\n <div class=\"filter-head\">\n <span class=\"filter-head-spacer\"></span>\n <span class=\"small\">\n Report bar\n </span>\n <span class=\"small\">\n This component\n </span>\n </div>\n @for (row of rows; track row.column) {\n <div class=\"filter-card\">\n <div class=\"filter-card-main\">\n <div class=\"filter-card-title\">\n {{ row.label }}\n </div>\n <div class=\"filter-card-meta\">\n {{ typeLabel(row.type) }}\n </div>\n </div>\n <mat-slide-toggle\n class=\"filter-toggle\"\n [ngModel]=\"row.report\"\n (ngModelChange)=\"setExposure(row, 'report', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show in the report bar\">\n </mat-slide-toggle>\n <mat-slide-toggle\n class=\"filter-toggle\"\n [ngModel]=\"row.component\"\n (ngModelChange)=\"setExposure(row, 'component', $event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n matTooltip=\"Show on this component\">\n </mat-slide-toggle>\n </div>\n }\n </div>\n } @else {\n <div class=\"filters-empty\">\n This component has no filterable columns yet \u2014 give it some SQL first.\n </div>\n }\n </div>\n </mat-tab>\n <mat-tab\n label=\"SQL\"\n name=\"sql\">\n <div class=\"tab-body\">\n <pre class=\"code-block\">\n {{ component.sql }}\n </pre>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Config\"\n name=\"config\">\n <div class=\"tab-body\">\n <pre class=\"code-block\">\n {{ configJson }}\n </pre>\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <fs-form-dialog-actions\n [save]=\"selectedTab === 'settings'\"\n [done]=\"selectedTab !== 'settings'\">\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}.geometry-row{display:flex;gap:8px}.geometry-row mat-form-field{flex:1}.filter-list{display:flex;flex-direction:column;gap:8px}.filter-head{display:flex;align-items:center;gap:8px;padding:0 10px 2px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;color:#9aa5b1}.filter-head .filter-head-spacer{flex:1}.filter-head .filter-head-toggle{width:110px;flex:0 0 auto;text-align:center}.filter-card{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid #e4e7eb;border-radius:8px;background:#fff}.filter-card .filter-card-main{flex:1;min-width:0}.filter-card .filter-card-main .filter-card-title{font-weight:600;font-size:13px;color:#1f2933;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.filter-card .filter-card-main .filter-card-meta{font-size:12px;color:#7b8794}.filter-card .filter-card-main .filter-card-meta code{background:#f0f4f8;border-radius:3px;padding:0 4px}.filter-card .filter-toggle{width:110px;flex:0 0 auto;display:flex;justify-content:center}.filters-empty{font-size:13px;color:#7b8794;padding:8px 0 4px}.filters-empty.error{color:#e15759}.code-block{margin:0;padding:10px 12px;background:#1f2933;color:#e4e7eb;border-radius:6px;font-family:JetBrains Mono,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow:auto}\n"] }]
2062
+ }] });
2063
+
2064
+ // Self-contained timezone picker for the report settings dialog. The IANA zone
2065
+ // list comes from the browser (Intl.supportedValuesOf), so the package carries no
2066
+ // dependency on any app timezone API. Defaults an empty value to the viewer's zone.
2067
+ class TimezoneSelectComponent {
2068
+ placeholder = 'Timezone';
2069
+ required = false;
2070
+ disabled = false;
2071
+ timezoneChange = new EventEmitter();
2072
+ set timezone(value) {
2073
+ if (value && value !== this._timezone) {
2074
+ this._timezone = value;
2075
+ this.timezoneChange.emit(this.timezone);
2076
+ }
2077
+ }
2078
+ get timezone() {
2079
+ return this._timezone;
2080
+ }
2081
+ guid = guid();
2082
+ timezones = [];
2083
+ _browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
2084
+ _timezone = null;
2085
+ ngOnInit() {
2086
+ this.timezones = this._supportedTimezones();
2087
+ if (!this.timezone) {
2088
+ this.timezone = this._browserTimezone;
2089
+ }
2090
+ }
2091
+ // The browser's IANA zone list where available; fall back to at least the
2092
+ // viewer's own zone so the control is never empty.
2093
+ _supportedTimezones() {
2094
+ const intl = Intl;
2095
+ return typeof intl.supportedValuesOf === 'function'
2096
+ ? intl.supportedValuesOf('timeZone')
2097
+ : [this._browserTimezone];
2098
+ }
2099
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TimezoneSelectComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2100
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: TimezoneSelectComponent, isStandalone: true, selector: "fs-ai-report-timezone-select", inputs: { placeholder: "placeholder", required: "required", disabled: "disabled", timezone: "timezone" }, outputs: { timezoneChange: "timezoneChange" }, ngImport: i0, template: "<mat-form-field class=\"full-width\">\n <mat-label>{{placeholder}}</mat-label>\n <mat-select\n [(ngModel)]=\"timezone\"\n [fsFormRequired]=\"required\"\n [disabled]=\"disabled\"\n name=\"timezone_select_{{ guid }}\">\n @for (item of timezones; track item) {\n <mat-option [value]=\"item\">{{ item }}</mat-option>\n }\n </mat-select>\n</mat-form-field>\n", styles: [".full-width{width:100%}\n"], dependencies: [{ kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "component", type: MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$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: FsFormModule }, { kind: "directive", type: i2$1.FsFormRequiredDirective, selector: "[fsFormRequired],[ngModel][required]", inputs: ["fsFormRequired", "required", "fsFormRequiredMessage"] }], viewProviders: [{ provide: ControlContainer, useExisting: NgForm }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2101
+ }
2102
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TimezoneSelectComponent, decorators: [{
2103
+ type: Component,
2104
+ args: [{ selector: 'fs-ai-report-timezone-select', viewProviders: [{ provide: ControlContainer, useExisting: NgForm }], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
2105
+ MatFormField,
2106
+ MatLabel,
2107
+ MatSelect,
2108
+ MatOption,
2109
+ FormsModule,
2110
+ FsFormModule,
2111
+ ], template: "<mat-form-field class=\"full-width\">\n <mat-label>{{placeholder}}</mat-label>\n <mat-select\n [(ngModel)]=\"timezone\"\n [fsFormRequired]=\"required\"\n [disabled]=\"disabled\"\n name=\"timezone_select_{{ guid }}\">\n @for (item of timezones; track item) {\n <mat-option [value]=\"item\">{{ item }}</mat-option>\n }\n </mat-select>\n</mat-form-field>\n", styles: [".full-width{width:100%}\n"] }]
2112
+ }], propDecorators: { placeholder: [{
2113
+ type: Input
2114
+ }], required: [{
2115
+ type: Input
2116
+ }], disabled: [{
2117
+ type: Input
2118
+ }], timezoneChange: [{
2119
+ type: Output
2120
+ }], timezone: [{
2121
+ type: Input
2122
+ }] } });
2123
+
2124
+ // Report settings, in two tabs:
2125
+ // Settings — name + page setup (size, orientation, layout mode). The layout
2126
+ // mode is the big one: Freeform (position anything anywhere) vs Flow
2127
+ // (components flow into rows by width %).
2128
+ // Styles — the report's typographic styles. Sizes are in POINTS so they
2129
+ // map 1:1 to PDF/PowerPoint; we start with the Heading size.
2130
+ class ReportSettingsComponent {
2131
+ selectedTab = 'settings';
2132
+ name = '';
2133
+ pageSize = 'widescreen';
2134
+ pageOrientation = 'landscape';
2135
+ layout = 'freeform';
2136
+ // Empty until the report has one; app-timezone-select then defaults it to the
2137
+ // viewer's browser zone (which Save persists, making the report's zone explicit).
2138
+ timezone = '';
2139
+ headingSize = DEFAULT_HEADING_SIZE;
2140
+ _data = inject(MAT_DIALOG_DATA);
2141
+ _dialogRef = inject(MatDialogRef);
2142
+ _reportData = inject(ReportData);
2143
+ _message = inject(FsMessage);
2144
+ _prompt = inject(FsPrompt);
2145
+ _destroyRef = inject(DestroyRef);
2146
+ ngOnInit() {
2147
+ const report = this._data.report;
2148
+ this.name = report.name;
2149
+ this.pageSize = report.pageSize;
2150
+ this.pageOrientation = report.pageOrientation;
2151
+ this.layout = report.layout ?? 'freeform';
2152
+ this.timezone = report.timezone ?? '';
2153
+ this.headingSize = report.config?.styles?.heading?.size ?? DEFAULT_HEADING_SIZE;
2154
+ }
2155
+ // Confirm and delete here, while the dialog is still open, then close with
2156
+ // a `deleted` result. The page reacts to the result; it no longer owns the
2157
+ // prompt or the API call.
2158
+ delete() {
2159
+ this._prompt.confirm({
2160
+ title: 'Delete Report',
2161
+ template: `Delete "${this._data.report.name}"? This cannot be undone.`,
2162
+ })
2163
+ .pipe(switchMap(() => this._reportData.delete(this._data.report.id)), takeUntilDestroyed(this._destroyRef))
2164
+ .subscribe(() => {
2165
+ this._message.success('Report deleted');
2166
+ this._dialogRef.close({ action: 'deleted' });
2167
+ });
2168
+ }
2169
+ save = () => {
2170
+ return this._reportData.update(this._data.report.id, {
2171
+ name: this.name,
2172
+ pageSize: this.pageSize,
2173
+ pageOrientation: this.pageOrientation,
2174
+ layout: this.layout,
2175
+ timezone: this.timezone,
2176
+ styles: { heading: { size: this.headingSize } },
2177
+ })
2178
+ .pipe(tap(() => {
2179
+ this._message.success('Report settings saved');
2180
+ this._dialogRef.close({ action: 'saved' });
2181
+ }));
2182
+ };
2183
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportSettingsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2184
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.14", type: ReportSettingsComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: "<form\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <h1 mat-dialog-title>\n Report Settings\n </h1>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"Settings\"\n name=\"settings\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Name\n </mat-label>\n <input\n matInput\n [(ngModel)]=\"name\"\n name=\"name\"\n [fsFormRequired]=\"true\">\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Page Size\n </mat-label>\n <mat-select\n [(ngModel)]=\"pageSize\"\n name=\"pageSize\">\n <mat-option value=\"widescreen\">\n Widescreen (13.33\" \u00D7 7.5\")\n </mat-option>\n <mat-option value=\"letter\">\n Letter (11\" \u00D7 8.5\")\n </mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Orientation\n </mat-label>\n <mat-select\n [(ngModel)]=\"pageOrientation\"\n name=\"pageOrientation\">\n <mat-option value=\"landscape\">\n Landscape\n </mat-option>\n <mat-option value=\"portrait\">\n Portrait\n </mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Layout\n </mat-label>\n <mat-select\n [(ngModel)]=\"layout\"\n name=\"layout\">\n <mat-option value=\"freeform\">\n Freeform \u2014 position components anywhere on the page\n </mat-option>\n <mat-option value=\"flow\">\n Flow \u2014 components flow into rows by width %\n </mat-option>\n </mat-select>\n <mat-hint>\n Freeform is like PowerPoint; Flow is like a responsive dashboard.\n </mat-hint>\n </mat-form-field>\n <fs-ai-report-timezone-select [(timezone)]=\"timezone\"></fs-ai-report-timezone-select>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Styles\"\n name=\"styles\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Heading Size\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"1\"\n min=\"6\"\n max=\"96\"\n [(ngModel)]=\"headingSize\"\n name=\"headingSize\"\n [fsFormMin]=\"6\"\n [fsFormMax]=\"96\">\n <span matTextSuffix>\n pt\n </span>\n <mat-hint>\n Applies to component titles. Points map 1:1 to PDF and PowerPoint.\n </mat-hint>\n </mat-form-field>\n </div>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <fs-form-dialog-actions>\n <button\n type=\"button\"\n mat-button\n color=\"warn\"\n (click)=\"delete()\">\n Delete\n </button>\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>", styles: ["mat-form-field{width:100%}.tab-body{padding-top:16px}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$3.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$3.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$3.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { kind: "directive", type: i1$3.MaxValidator, selector: "input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]", inputs: ["max"] }, { kind: "directive", type: i1$3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$3.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$1.FsFormDirective, selector: "[fsForm]", inputs: ["wrapperSelector", "messageSelector", "hintSelector", "labelSelector", "autocomplete", "shortcuts", "confirm", "confirmDialog", "confirmDrawer", "confirmBrowser", "dirtySubmitButton", "submit", "successDelay", "errorDelay", "deactivationGuard"], outputs: ["fsForm", "invalid", "valid", "submitted", "reseted", "cleared"], exportAs: ["fsForm"] }, { kind: "directive", type: i2$1.FsFormRequiredDirective, selector: "[fsFormRequired],[ngModel][required]", inputs: ["fsFormRequired", "required", "fsFormRequiredMessage"] }, { kind: "directive", type: i2$1.FsFormMinDirective, selector: "[fsFormMin]", inputs: ["fsFormMin", "fsFormMinMessage"] }, { kind: "directive", type: i2$1.FsFormMaxDirective, selector: "[fsFormMax]", inputs: ["fsFormMax", "fsFormMaxMessage"] }, { kind: "component", type: i2$1.FsFormDialogActionsComponent, selector: "fs-form-dialog-actions", inputs: ["save", "create", "close", "done", "closeData", "name"] }, { kind: "directive", type: i2$1.FsFormNoFsValidatorsDirective, selector: "[ngModel]:not([required]):not([fsFormRequired]):not([fsFormCompare]):not([fsFormDateRange]):not([fsFormEmail]):not([fsFormEmails]):not([fsFormFunction]):not([fsFormGreater]):not([fsFormGreaterEqual]):not([fsFormInteger]):not([fsFormLesser]):not([fsFormMax]):not([fsFormMaxLength]):not([fsFormMin]):not([fsFormMinLength]):not([fsFormNumeric]):not([fsFormPattern]):not([fsFormPhone]):not([fsFormUrl]):not([validate])" }, { kind: "directive", type: i2$1.FsButtonDirective, selector: "[mat-raised-button],[mat-button],[mat-flat-button],[mat-stroked-button]", inputs: ["name", "dirtySubmit"] }, { kind: "ngmodule", type: FsDialogModule }, { kind: "component", type: i3.FsDialogComponent, selector: "fs-dialog", inputs: ["mobileMode", "mobileButtonPlacement", "mobileWidth", "mode", "buttonLayout"] }, { kind: "ngmodule", type: FsTabsModule }, { kind: "directive", type: i4.FsTabsHeaderTabGroupDirective, selector: "mat-tab-group, matTabGroup, [matTabGroup]", inputs: ["orientation", "selected", "selectedData"], outputs: ["selectedChange", "selectedDataChange"], exportAs: ["fsTabsHeaderTabGroup"] }, { kind: "directive", type: i4.FsTabsTabDirective, selector: "mat-tab,matTab", inputs: ["name", "data"], exportAs: ["fsTabsTab"] }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i5.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass"], exportAs: ["matTab"] }, { kind: "component", type: i5.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "directive", type: MatDialogTitle, selector: "[mat-dialog-title], [matDialogTitle]", inputs: ["id"], exportAs: ["matDialogTitle"] }, { kind: "directive", type: MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "directive", type: MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "component", type: MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: MatLabel, selector: "mat-label" }, { kind: "directive", type: MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "directive", type: MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "directive", type: MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly"], exportAs: ["matInput"] }, { kind: "component", type: MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: MatOption$1, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "component", type: TimezoneSelectComponent, selector: "fs-ai-report-timezone-select", inputs: ["placeholder", "required", "disabled", "timezone"], outputs: ["timezoneChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2185
+ }
2186
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportSettingsComponent, decorators: [{
2187
+ type: Component,
2188
+ args: [{ changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
2189
+ FormsModule,
2190
+ FsFormModule,
2191
+ FsDialogModule,
2192
+ FsTabsModule,
2193
+ MatTabsModule,
2194
+ MatButton,
2195
+ MatDialogTitle,
2196
+ MatDialogContent,
2197
+ MatDialogActions,
2198
+ MatFormField,
2199
+ MatLabel,
2200
+ MatHint,
2201
+ MatSuffix,
2202
+ MatInput,
2203
+ MatSelect,
2204
+ MatOption$1,
2205
+ TimezoneSelectComponent,
2206
+ ], 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 <mat-form-field>\n <mat-label>\n Page Size\n </mat-label>\n <mat-select\n [(ngModel)]=\"pageSize\"\n name=\"pageSize\">\n <mat-option value=\"widescreen\">\n Widescreen (13.33\" \u00D7 7.5\")\n </mat-option>\n <mat-option value=\"letter\">\n Letter (11\" \u00D7 8.5\")\n </mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Orientation\n </mat-label>\n <mat-select\n [(ngModel)]=\"pageOrientation\"\n name=\"pageOrientation\">\n <mat-option value=\"landscape\">\n Landscape\n </mat-option>\n <mat-option value=\"portrait\">\n Portrait\n </mat-option>\n </mat-select>\n </mat-form-field>\n <mat-form-field>\n <mat-label>\n Layout\n </mat-label>\n <mat-select\n [(ngModel)]=\"layout\"\n name=\"layout\">\n <mat-option value=\"freeform\">\n Freeform \u2014 position components anywhere on the page\n </mat-option>\n <mat-option value=\"flow\">\n Flow \u2014 components flow into rows by width %\n </mat-option>\n </mat-select>\n <mat-hint>\n Freeform is like PowerPoint; Flow is like a responsive dashboard.\n </mat-hint>\n </mat-form-field>\n <fs-ai-report-timezone-select [(timezone)]=\"timezone\"></fs-ai-report-timezone-select>\n </div>\n </mat-tab>\n <mat-tab\n label=\"Styles\"\n name=\"styles\">\n <div class=\"fs-column tab-body\">\n <mat-form-field>\n <mat-label>\n Heading Size\n </mat-label>\n <input\n matInput\n type=\"number\"\n step=\"1\"\n min=\"6\"\n max=\"96\"\n [(ngModel)]=\"headingSize\"\n name=\"headingSize\"\n [fsFormMin]=\"6\"\n [fsFormMax]=\"96\">\n <span matTextSuffix>\n pt\n </span>\n <mat-hint>\n Applies to component titles. Points map 1:1 to PDF and PowerPoint.\n </mat-hint>\n </mat-form-field>\n </div>\n </mat-tab>\n </mat-tab-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"] }]
2207
+ }] });
2208
+
2209
+ // The assembled report structure returned by GET /api/reports/:id — composed
2210
+ // server-side from the normalized tables (reports → report_pages →
2211
+ // report_components + report_filters, plus report_filter_groups). All keys are
2212
+ // camelCase; component `config` is the standard format the renderer maps to
2213
+ // ECharts options / FsList config.
2214
+ // The runtime Frequency control's choices — re-buckets every time-series chart
2215
+ // at view time, overriding each chart's authored granularity. 'year' reads as
2216
+ // "Annually" in the control.
2217
+ const FREQUENCY_OPTIONS = [
2218
+ { value: 'day', name: 'Day' },
2219
+ { value: 'week', name: 'Week' },
2220
+ { value: 'month', name: 'Month' },
2221
+ { value: 'quarter', name: 'Quarter' },
2222
+ { value: 'year', name: 'Annually' },
2223
+ ];
2224
+
2225
+ // Rows fetched for a list component's export table — enough to fill a
2226
+ // slide/page table; the cap note covers the rest.
2227
+ const LIST_EXPORT_ROWS = 100;
2228
+ // Gathers everything an export needs: every component's data across ALL pages
2229
+ // (the canvas only renders the active page, so exports can't scrape the
2230
+ // screen), fetched fresh with the viewer's current filter state. One failed
2231
+ // component degrades to a note in the deck, never a failed export.
2232
+ class ReportExportCollectorService {
2233
+ _reportData = inject(ReportData);
2234
+ _filterState = inject(ReportFilterStateService);
2235
+ collect(report) {
2236
+ const requests = [];
2237
+ const slots = [];
2238
+ report.pages.forEach((page, pageIndex) => {
2239
+ for (const component of page.components) {
2240
+ slots.push({ pageIndex });
2241
+ requests.push(this._componentData(report, component));
2242
+ }
2243
+ });
2244
+ const all$ = requests.length ? forkJoin(requests) : of([]);
2245
+ return all$
2246
+ .pipe(map$1((collected) => {
2247
+ const pages = report.pages.map((page) => ({ page, components: [] }));
2248
+ collected.forEach((item, index) => {
2249
+ pages[slots[index].pageIndex].components.push(item);
2250
+ });
2251
+ return {
2252
+ report,
2253
+ pages,
2254
+ filterSummary: this._filterSummary(report),
2255
+ };
2256
+ }));
2257
+ }
2258
+ _componentData(report, component) {
2259
+ const filters = this._filterState.resolveForComponent(component);
2260
+ // A time-series chart carries the viewer's Frequency override so the export
2261
+ // re-buckets exactly as the screen does; lists carry their export paging.
2262
+ const frequency = this._filterState.frequency();
2263
+ const isTimeSeries = component.type === 'chart' && component.config?.xAxis?.kind === 'time';
2264
+ const state = component.type === 'list'
2265
+ ? { page: 1, pageSize: LIST_EXPORT_ROWS }
2266
+ : (isTimeSeries && frequency ? { frequency } : {});
2267
+ return this._reportData.componentData(report.id, component.id, filters, state)
2268
+ .pipe(map$1((data) => ({ component, data })), catchError(() => of({ component, data: null })));
2269
+ }
2270
+ // "Report Period: Jun 12, 2025 – Jun 12, 2026", "Organization: A, B" — one
2271
+ // line per filter group that has an active session value.
2272
+ _filterSummary(report) {
2273
+ const lines = [];
2274
+ for (const group of report.filterGroups ?? []) {
2275
+ const value = this._filterState.value(group.id);
2276
+ if (!value) {
2277
+ continue;
2278
+ }
2279
+ const label = group.label || 'Filter';
2280
+ if (value.start || value.end) {
2281
+ lines.push(`${label}: ${this._date(value.start)} – ${this._date(value.end)}`);
2282
+ }
2283
+ else if (value.values?.length) {
2284
+ lines.push(`${label}: ${value.values.map((item) => String(item)).join(', ')}`);
2285
+ }
2286
+ else if (value.value?.trim()) {
2287
+ lines.push(`${label}: "${value.value.trim()}"`);
2288
+ }
2289
+ }
2290
+ const frequency = this._filterState.frequency();
2291
+ if (frequency) {
2292
+ const option = FREQUENCY_OPTIONS.find((item) => item.value === frequency);
2293
+ lines.push(`Frequency: ${option?.name ?? frequency}`);
2294
+ }
2295
+ return lines;
2296
+ }
2297
+ _date(value) {
2298
+ if (!value) {
2299
+ return '…';
2300
+ }
2301
+ const date = value instanceof Date ? value : new Date(value);
2302
+ return Number.isNaN(date.getTime()) ? String(value) : format(date, 'MMM d, yyyy');
2303
+ }
2304
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportExportCollectorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2305
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportExportCollectorService });
2306
+ }
2307
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportExportCollectorService, decorators: [{
2308
+ type: Injectable
2309
+ }] });
2310
+
2311
+ // Renders a chart to a PNG data URL WITHOUT it being on screen — the PDF
2312
+ // export needs every page's charts, but the canvas only renders the active
2313
+ // page. ECharts happily initializes against a detached element when given
2314
+ // explicit pixel dimensions; animations are disabled so getDataURL is
2315
+ // complete synchronously after setOption.
2316
+ async function renderChartImage(component, data, width, height, pixelRatio = 3) {
2317
+ // The same tree-shaken echarts build + 'report' theme the live canvas uses.
2318
+ const echarts = (await import('./firestitch-report-echarts-BxYnpz7n.mjs')).default;
2319
+ const host = document.createElement('div');
2320
+ host.style.width = `${width}px`;
2321
+ host.style.height = `${height}px`;
2322
+ const chart = echarts.init(host, 'report', {
2323
+ renderer: 'canvas',
2324
+ width,
2325
+ height,
2326
+ });
2327
+ try {
2328
+ chart.setOption({
2329
+ ...buildChartOption(component, data),
2330
+ animation: false,
2331
+ });
2332
+ return chart.getDataURL({
2333
+ type: 'png',
2334
+ pixelRatio,
2335
+ backgroundColor: '#ffffff',
2336
+ });
2337
+ }
2338
+ finally {
2339
+ chart.dispose();
2340
+ }
2341
+ }
2342
+
2343
+ // Inches → points (the PDF unit).
2344
+ const PT = 72;
2345
+ // Component title strip height, points.
2346
+ const TITLE_H$1 = 18;
2347
+ // Approximate table row height, points — drives the rows-that-fit cap.
2348
+ const TABLE_ROW_H$1 = 16;
2349
+ // Builds the PDF client-side with jspdf. The report's unit is inches and PDFs
2350
+ // are points at exactly 72/inch, so each report page becomes one PDF page of
2351
+ // the exact physical size and components land at their precise positions.
2352
+ // Charts are rendered through an offscreen ECharts instance at 3× pixel
2353
+ // ratio — pixel-identical to the screen, crisp at print size — and tables are
2354
+ // drawn with jspdf-autotable. Both libraries load lazily into their own chunks.
2355
+ class ReportPdfService {
2356
+ async export(collected) {
2357
+ const { jsPDF } = await import('jspdf');
2358
+ const autoTable = (await import('jspdf-autotable')).default;
2359
+ const report = collected.report;
2360
+ const pageW = report.pageWidth * PT;
2361
+ const pageH = report.pageHeight * PT;
2362
+ const doc = new jsPDF({
2363
+ orientation: pageW >= pageH ? 'landscape' : 'portrait',
2364
+ unit: 'pt',
2365
+ format: [pageW, pageH],
2366
+ });
2367
+ this._titlePage(doc, collected, pageW, pageH);
2368
+ for (const collectedPage of collected.pages) {
2369
+ doc.addPage([pageW, pageH], pageW >= pageH ? 'landscape' : 'portrait');
2370
+ const geometry = pageGeometry(report, collectedPage.page);
2371
+ for (const item of collectedPage.components) {
2372
+ await this._renderComponent(doc, autoTable, item, geometry.get(item.component.id));
2373
+ }
2374
+ }
2375
+ doc.save(`${this._slug(report.name)}.pdf`);
2376
+ }
2377
+ _titlePage(doc, collected, pageW, pageH) {
2378
+ doc.setFont('helvetica', 'bold');
2379
+ doc.setFontSize(28);
2380
+ doc.setTextColor('#1f2933');
2381
+ doc.text(collected.report.name, 40, pageH * 0.35);
2382
+ doc.setFont('helvetica', 'normal');
2383
+ doc.setFontSize(11);
2384
+ doc.setTextColor('#52606d');
2385
+ const lines = [
2386
+ format(new Date(), 'MMMM d, yyyy'),
2387
+ ...collected.filterSummary,
2388
+ ];
2389
+ doc.text(lines, 40, pageH * 0.35 + 26);
2390
+ }
2391
+ async _renderComponent(doc, autoTable, item, geometry) {
2392
+ const component = item.component;
2393
+ // Geometry arrives in inches (flow layout pre-resolved); the PDF draws in
2394
+ // points.
2395
+ const inches = geometry ?? component;
2396
+ const x = inches.x * PT;
2397
+ const y = inches.y * PT;
2398
+ const w = inches.w * PT;
2399
+ const h = inches.h * PT;
2400
+ if (component.title) {
2401
+ doc.setFont('helvetica', 'bold');
2402
+ doc.setFontSize(9);
2403
+ doc.setTextColor('#1f2933');
2404
+ doc.text(component.title, x, y + 10, { maxWidth: w });
2405
+ }
2406
+ const bodyY = y + TITLE_H$1;
2407
+ const bodyH = Math.max(20, h - TITLE_H$1);
2408
+ if (!item.data || !item.data.rows?.length) {
2409
+ doc.setFont('helvetica', 'italic');
2410
+ doc.setFontSize(9);
2411
+ doc.setTextColor('#7b8794');
2412
+ doc.text(item.data ? 'No data' : 'Data unavailable', x + w / 2, bodyY + bodyH / 2, { align: 'center' });
2413
+ return;
2414
+ }
2415
+ switch (component.type) {
2416
+ case 'chart': {
2417
+ const image = await renderChartImage(component, item.data, w, bodyH);
2418
+ doc.addImage(image, 'PNG', x, bodyY, w, bodyH);
2419
+ break;
2420
+ }
2421
+ case 'list':
2422
+ this._renderTable(doc, autoTable, component, item.data, x, bodyY, w, bodyH);
2423
+ break;
2424
+ case 'kpi':
2425
+ this._renderKpi(doc, component, item.data, x, bodyY, w, bodyH);
2426
+ break;
2427
+ }
2428
+ }
2429
+ _renderTable(doc, autoTable, component, data, x, y, w, h) {
2430
+ const config = component.config;
2431
+ const columns = config.columns?.length
2432
+ ? config.columns
2433
+ : data.columns.map((column) => ({ column, label: column }));
2434
+ const cap = Math.max(2, Math.floor(h / TABLE_ROW_H$1) - 1);
2435
+ const rows = data.rows.slice(0, cap);
2436
+ const remaining = (data.paging?.total ?? data.rows.length) - rows.length;
2437
+ const body = rows.map((row) => columns.map((column) => this._cell(row[column.column], column.format)));
2438
+ if (remaining > 0) {
2439
+ body.push([`… ${remaining} more row(s) — use the component's CSV export for the full dataset`]);
2440
+ }
2441
+ autoTable(doc, {
2442
+ head: [columns.map((column) => column.label || column.column)],
2443
+ body,
2444
+ startY: y,
2445
+ margin: { left: x },
2446
+ tableWidth: w,
2447
+ styles: { fontSize: 8, cellPadding: 3, textColor: '#1f2933' },
2448
+ headStyles: { fillColor: '#4e79a7', textColor: '#ffffff', fontStyle: 'bold' },
2449
+ alternateRowStyles: { fillColor: '#f5f7fa' },
2450
+ theme: 'grid',
2451
+ });
2452
+ }
2453
+ _renderKpi(doc, component, data, x, y, w, h) {
2454
+ const config = component.config;
2455
+ const row = data.rows[0] ?? {};
2456
+ const column = config.measure?.column ?? Object.keys(row)[0];
2457
+ const value = formatMeasureValue(row[column], config.measure?.format);
2458
+ doc.setFont('helvetica', 'bold');
2459
+ doc.setFontSize(26);
2460
+ doc.setTextColor('#1f2933');
2461
+ doc.text(value, x + w / 2, y + h / 2, { align: 'center' });
2462
+ if (config.measure?.label) {
2463
+ doc.setFont('helvetica', 'normal');
2464
+ doc.setFontSize(9);
2465
+ doc.setTextColor('#7b8794');
2466
+ doc.text(config.measure.label, x + w / 2, y + h / 2 + 16, { align: 'center' });
2467
+ }
2468
+ }
2469
+ _cell(value, columnFormat) {
2470
+ if (value === null || value === undefined) {
2471
+ return '';
2472
+ }
2473
+ if (columnFormat === 'date') {
2474
+ const date = new Date(String(value));
2475
+ return Number.isNaN(date.getTime()) ? String(value) : format(date, 'MMM d, yyyy');
2476
+ }
2477
+ if (columnFormat === 'number') {
2478
+ return formatMeasureValue(value, 'number');
2479
+ }
2480
+ return String(value);
2481
+ }
2482
+ _slug(name) {
2483
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'report';
2484
+ }
2485
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportPdfService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2486
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportPdfService });
2487
+ }
2488
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportPdfService, decorators: [{
2489
+ type: Injectable
2490
+ }] });
2491
+
2492
+ // The report chart palette — one definition shared by the ECharts theme (on
2493
+ // screen), the PowerPoint export's native chart colors, and the PDF export,
2494
+ // so a series keeps its color across every medium.
2495
+ const REPORT_CHART_COLORS = [
2496
+ '4e79a7', 'f28e2b', 'e15759', '76b7b2', '59a14f',
2497
+ 'edc948', 'b07aa1', 'ff9da7', '9c755f', 'bab0ac',
2498
+ ];
2499
+ // CSS-hex form for ECharts.
2500
+ const REPORT_CHART_COLORS_CSS = REPORT_CHART_COLORS.map((color) => `#${color}`);
2501
+
2502
+ // Component title strip height on the slide, inches.
2503
+ const TITLE_H = 0.28;
2504
+ // Approximate table row height, inches — drives the rows-that-fit cap.
2505
+ const TABLE_ROW_H = 0.26;
2506
+ // Our legend positions mapped to pptxgenjs's single-letter legendPos codes.
2507
+ // 'hidden' has no slot — showLegend is turned off instead — so it falls back
2508
+ // to top.
2509
+ const PPTX_LEGEND_POS = {
2510
+ top: 't',
2511
+ bottom: 'b',
2512
+ left: 'l',
2513
+ right: 'r',
2514
+ hidden: 't',
2515
+ };
2516
+ // Builds the PowerPoint deck client-side with pptxgenjs. Charts are NATIVE
2517
+ // editable PowerPoint charts (slide.addChart writes real chart XML with an
2518
+ // embedded data workbook — "Edit Data" works), never images. The page IS the
2519
+ // slide: the report's unit is inches — exactly PowerPoint's unit — so the
2520
+ // layout and every component position transfer with no conversion at all.
2521
+ // pptxgenjs is loaded lazily so it lives in its own webpack chunk.
2522
+ class ReportPptxService {
2523
+ async export(collected) {
2524
+ const PptxGenJS = (await import('pptxgenjs')).default;
2525
+ const report = collected.report;
2526
+ const pageW = report.pageWidth;
2527
+ const pageH = report.pageHeight;
2528
+ const pptx = new PptxGenJS();
2529
+ pptx.defineLayout({ name: 'REPORT', width: pageW, height: pageH });
2530
+ pptx.layout = 'REPORT';
2531
+ pptx.title = report.name;
2532
+ this._titleSlide(pptx, collected, pageW, pageH);
2533
+ for (const collectedPage of collected.pages) {
2534
+ const slide = pptx.addSlide();
2535
+ const geometry = pageGeometry(report, collectedPage.page);
2536
+ for (const item of collectedPage.components) {
2537
+ this._renderComponent(pptx, slide, item, geometry.get(item.component.id));
2538
+ }
2539
+ }
2540
+ await pptx.writeFile({ fileName: `${this._slug(report.name)}.pptx` });
2541
+ }
2542
+ _titleSlide(pptx, collected, pageW, pageH) {
2543
+ const slide = pptx.addSlide();
2544
+ slide.addText(collected.report.name, {
2545
+ x: 0.5, y: pageH * 0.3, w: pageW - 1, h: 0.8,
2546
+ fontSize: 30, bold: true, color: '1F2933',
2547
+ });
2548
+ const lines = [
2549
+ format(new Date(), 'MMMM d, yyyy'),
2550
+ ...collected.filterSummary,
2551
+ ];
2552
+ slide.addText(lines.join('\n'), {
2553
+ x: 0.5, y: pageH * 0.3 + 0.9, w: pageW - 1, h: Math.max(0.4, lines.length * 0.25),
2554
+ fontSize: 12, color: '52606D',
2555
+ });
2556
+ }
2557
+ _renderComponent(pptx, slide, item, geometry) {
2558
+ const component = item.component;
2559
+ // Geometry is in inches — PowerPoint's native unit (flow layout arrives
2560
+ // pre-resolved into rows by pageGeometry).
2561
+ const { x, y, w, h } = geometry ?? component;
2562
+ if (component.title) {
2563
+ slide.addText(component.title, {
2564
+ x, y, w, h: TITLE_H,
2565
+ fontSize: 10, bold: true, color: '1F2933', valign: 'top',
2566
+ });
2567
+ }
2568
+ const bodyY = y + TITLE_H;
2569
+ const bodyH = Math.max(0.3, h - TITLE_H);
2570
+ if (!item.data || !item.data.rows?.length) {
2571
+ slide.addText(item.data ? 'No data' : 'Data unavailable', {
2572
+ x, y: bodyY, w, h: bodyH,
2573
+ fontSize: 10, italic: true, color: '7B8794', align: 'center', valign: 'middle',
2574
+ });
2575
+ return;
2576
+ }
2577
+ switch (component.type) {
2578
+ case 'chart':
2579
+ this._renderChart(pptx, slide, component, item.data, x, bodyY, w, bodyH);
2580
+ break;
2581
+ case 'list':
2582
+ this._renderTable(slide, component, item.data, x, bodyY, w, bodyH);
2583
+ break;
2584
+ case 'kpi':
2585
+ this._renderKpi(slide, component, item.data, x, bodyY, w, bodyH);
2586
+ break;
2587
+ }
2588
+ }
2589
+ _renderChart(pptx, slide, component, data, x, y, w, h) {
2590
+ const config = component.config;
2591
+ const chartType = config.chartType ?? 'bar';
2592
+ const extracted = extractChartSeries(component, data);
2593
+ const typeMap = {
2594
+ bar: pptx.ChartType.bar,
2595
+ line: pptx.ChartType.line,
2596
+ area: pptx.ChartType.area,
2597
+ pie: pptx.ChartType.pie,
2598
+ donut: pptx.ChartType.doughnut,
2599
+ };
2600
+ const pie = chartType === 'pie' || chartType === 'donut';
2601
+ const colorBy = isColorBy(config);
2602
+ // Compact period labels match the on-screen axis (year only when the data
2603
+ // spans years); PowerPoint can't do a styled second line, so it's inline.
2604
+ const granularity = data.granularity;
2605
+ const labels = granularity
2606
+ ? extracted.categories.map((category) => formatPeriodLabel(category, granularity, spansMultipleYears(extracted.categories)))
2607
+ : extracted.categories;
2608
+ // Native chart data: one entry per series (pie charts take exactly one).
2609
+ // colorBy emits one series per category for the on-screen overlap; PowerPoint
2610
+ // has no equivalent overlap, so collapse them back into a single series (each
2611
+ // category's value sits at its own slot) — one full-width bar per category —
2612
+ // rather than letting PowerPoint cluster them into slivers.
2613
+ const chartData = colorBy
2614
+ ? [{
2615
+ name: config.measures?.[0]?.label ?? config.colorBy?.label ?? '',
2616
+ labels,
2617
+ values: extracted.categories.map((_, index) => extracted.series[index]?.values[index] ?? 0),
2618
+ }]
2619
+ : (pie ? extracted.series.slice(0, 1) : extracted.series)
2620
+ .map((series) => ({
2621
+ name: series.name,
2622
+ labels,
2623
+ values: series.values.map((value) => value ?? 0),
2624
+ }));
2625
+ // A single-series colorBy chart can't carry a per-category legend in a native
2626
+ // PowerPoint bar chart; the category axis labels identify each bar instead.
2627
+ const legendPosition = colorBy
2628
+ ? 'hidden'
2629
+ : resolveLegendPosition(config, extracted.series.length);
2630
+ slide.addChart(typeMap[chartType], chartData, {
2631
+ x, y, w, h,
2632
+ chartColors: REPORT_CHART_COLORS,
2633
+ showLegend: legendPosition !== 'hidden',
2634
+ legendPos: PPTX_LEGEND_POS[legendPosition],
2635
+ legendFontSize: 9,
2636
+ barDir: config.orientation === 'horizontal' ? 'bar' : 'col',
2637
+ barGrouping: config.stacked ? 'stacked' : 'clustered',
2638
+ holeSize: chartType === 'donut' ? 55 : undefined,
2639
+ catAxisLabelFontSize: 9,
2640
+ valAxisLabelFontSize: 9,
2641
+ showCatAxisTitle: !!config.xAxis?.label,
2642
+ catAxisTitle: config.xAxis?.label,
2643
+ catAxisTitleFontSize: 10,
2644
+ showValAxisTitle: !!config.measures?.[0]?.label && !pie,
2645
+ valAxisTitle: config.measures?.[0]?.label,
2646
+ valAxisTitleFontSize: 10,
2647
+ dataBorder: { pt: 0, color: 'FFFFFF' },
2648
+ });
2649
+ }
2650
+ _renderTable(slide, component, data, x, y, w, h) {
2651
+ const config = component.config;
2652
+ const columns = config.columns?.length
2653
+ ? config.columns
2654
+ : data.columns.map((column) => ({ column, label: column }));
2655
+ const cap = Math.max(2, Math.floor(h / TABLE_ROW_H) - 1);
2656
+ const rows = data.rows.slice(0, cap);
2657
+ const remaining = (data.paging?.total ?? data.rows.length) - rows.length;
2658
+ const header = columns.map((column) => ({
2659
+ text: column.label || column.column,
2660
+ options: { bold: true, color: 'FFFFFF', fill: { color: '4E79A7' } },
2661
+ }));
2662
+ const body = rows.map((row) => columns.map((column) => ({
2663
+ text: this._cell(row[column.column], column.format),
2664
+ options: {},
2665
+ })));
2666
+ if (remaining > 0) {
2667
+ body.push([{
2668
+ text: `… ${remaining} more row(s) — use the component's CSV export for the full dataset`,
2669
+ options: { colspan: columns.length, italic: true, color: '7B8794' },
2670
+ }]);
2671
+ }
2672
+ slide.addTable([header, ...body], {
2673
+ x, y, w,
2674
+ fontSize: 9,
2675
+ color: '1F2933',
2676
+ border: { type: 'solid', pt: 0.5, color: 'E4E7EB' },
2677
+ autoPage: false,
2678
+ rowH: TABLE_ROW_H,
2679
+ valign: 'middle',
2680
+ });
2681
+ }
2682
+ _renderKpi(slide, component, data, x, y, w, h) {
2683
+ const config = component.config;
2684
+ const row = data.rows[0] ?? {};
2685
+ const column = config.measure?.column ?? Object.keys(row)[0];
2686
+ const value = formatMeasureValue(row[column], config.measure?.format);
2687
+ slide.addText([
2688
+ { text: value, options: { fontSize: 28, bold: true, color: '1F2933', breakLine: true } },
2689
+ { text: config.measure?.label ?? '', options: { fontSize: 10, color: '7B8794' } },
2690
+ ], {
2691
+ x, y, w, h,
2692
+ align: 'center',
2693
+ valign: 'middle',
2694
+ });
2695
+ }
2696
+ _cell(value, columnFormat) {
2697
+ if (value === null || value === undefined) {
2698
+ return '';
2699
+ }
2700
+ if (columnFormat === 'date') {
2701
+ const date = new Date(String(value));
2702
+ return Number.isNaN(date.getTime()) ? String(value) : format(date, 'MMM d, yyyy');
2703
+ }
2704
+ if (columnFormat === 'number') {
2705
+ return formatMeasureValue(value, 'number');
2706
+ }
2707
+ return String(value);
2708
+ }
2709
+ _slug(name) {
2710
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'report';
2711
+ }
2712
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportPptxService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2713
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportPptxService });
2714
+ }
2715
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportPptxService, decorators: [{
2716
+ type: Injectable
2717
+ }] });
2718
+
2719
+ // Holds the report list in memory so the picker doesn't hit the API on every
2720
+ // focus. Loaded once when the page opens; create/rename/delete keep the cache
2721
+ // in sync.
2722
+ class ReportService {
2723
+ _reportData = inject(ReportData);
2724
+ _reports$ = new BehaviorSubject([]);
2725
+ get reports$() {
2726
+ return this._reports$.asObservable();
2727
+ }
2728
+ load() {
2729
+ return this._reportData.reports()
2730
+ .pipe(tap((reports) => this._reports$.next(reports ?? [])));
2731
+ }
2732
+ // Filters the cached list locally — what the autocomplete's [fetch] calls.
2733
+ filter(keyword) {
2734
+ const term = (keyword ?? '').trim().toLowerCase();
2735
+ const reports = this._reports$.getValue();
2736
+ if (!term) {
2737
+ return of(reports);
2738
+ }
2739
+ return of(reports.filter((report) => report.name.toLowerCase().includes(term)));
2740
+ }
2741
+ create(name) {
2742
+ return this._reportData.create(name)
2743
+ .pipe(tap((report) => {
2744
+ this._reports$.next([...this._reports$.getValue(), report]);
2745
+ }));
2746
+ }
2747
+ rename(reportId, name) {
2748
+ return this._reportData.rename(reportId, name)
2749
+ .pipe(tap((report) => {
2750
+ this._reports$.next(this._reports$.getValue()
2751
+ .map((existing) => (existing.id === report.id ? report : existing)));
2752
+ }));
2753
+ }
2754
+ delete(reportId) {
2755
+ return this._reportData.delete(reportId)
2756
+ .pipe(tap(() => {
2757
+ this._reports$.next(this._reports$.getValue().filter((report) => report.id !== reportId));
2758
+ }), map(() => undefined));
2759
+ }
2760
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2761
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportService, providedIn: 'root' });
2762
+ }
2763
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportService, decorators: [{
2764
+ type: Injectable,
2765
+ args: [{
2766
+ providedIn: 'root',
2767
+ }]
2768
+ }] });
2769
+
2770
+ // fs-filter query key for the runtime Frequency control (a render-layer bucket
2771
+ // override, not a filter group — so it's read straight off the query).
2772
+ const FREQUENCY_ITEM = 'frequency';
2773
+ // The Reports page: AI chat on the left, the report canvas on the right —
2774
+ // a split-pane layout; the viewer is our own canvas (no iframe, no embed URLs).
2775
+ // The chat is always scoped to the selected report; its id rides on every
2776
+ // turn via [requestData].
2777
+ class ReportComponent {
2778
+ // The single report the viewer and chat are focused on.
2779
+ selected = null;
2780
+ report = null;
2781
+ loadingReports = true;
2782
+ editMode = false;
2783
+ // The report toolbar IS an fs-filter: the picker rides in its heading
2784
+ // template, the menu becomes its actions, and report-level filter groups
2785
+ // become its items. Rebuilt whenever the loaded report (and thus its groups)
2786
+ // changes; the menu's show-guards keep working while no report is selected.
2787
+ reportFilterConfig;
2788
+ _split;
2789
+ _chatPanel;
2790
+ introMessage = {
2791
+ text: '',
2792
+ };
2793
+ _reportData = inject(ReportData);
2794
+ _reportService = inject(ReportService);
2795
+ _filterState = inject(ReportFilterStateService);
2796
+ _exportCollector = inject(ReportExportCollectorService);
2797
+ _pptxService = inject(ReportPptxService);
2798
+ _pdfService = inject(ReportPdfService);
2799
+ _process = inject(FsProcess);
2800
+ _prompt = inject(FsPrompt);
2801
+ _dialog = inject(MatDialog);
2802
+ _destroyRef = inject(DestroyRef);
2803
+ _cdRef = inject(ChangeDetectorRef);
2804
+ _zone = inject(NgZone);
2805
+ constructor() {
2806
+ this._buildReportConfig();
2807
+ this._reportService.load()
2808
+ .pipe(finalize(() => {
2809
+ this.loadingReports = false;
2810
+ this._cdRef.markForCheck();
2811
+ }), takeUntilDestroyed(this._destroyRef))
2812
+ .subscribe({ error: () => undefined });
2813
+ }
2814
+ fetchReports = (keyword) => {
2815
+ return this._reportService.filter(keyword);
2816
+ };
2817
+ reportChange(report) {
2818
+ this.report = null;
2819
+ this.editMode = false;
2820
+ this._buildReportConfig();
2821
+ if (report) {
2822
+ this._loadReport(report.id);
2823
+ }
2824
+ }
2825
+ createReport() {
2826
+ this._prompt.input({
2827
+ title: 'Create Report',
2828
+ label: 'Name',
2829
+ commitLabel: 'Create',
2830
+ required: true,
2831
+ })
2832
+ .pipe(switchMap((name) => this._reportService.create(name)), takeUntilDestroyed(this._destroyRef))
2833
+ .subscribe((report) => {
2834
+ this.selected = report;
2835
+ this._cdRef.markForCheck();
2836
+ this._loadReport(report.id);
2837
+ });
2838
+ }
2839
+ toggleEditMode() {
2840
+ this.editMode = !this.editMode;
2841
+ }
2842
+ // Report settings — name, page size, orientation, layout mode. The picker
2843
+ // list and structure both reload on save (a layout switch re-renders the
2844
+ // whole canvas).
2845
+ reportSettings() {
2846
+ if (!this.report) {
2847
+ return;
2848
+ }
2849
+ this._dialog.open(ReportSettingsComponent, {
2850
+ data: { report: this.report },
2851
+ width: '460px',
2852
+ })
2853
+ .afterClosed()
2854
+ .pipe(takeUntilDestroyed(this._destroyRef))
2855
+ .subscribe((result) => {
2856
+ // The dialog confirms + performs the delete itself; we just react to
2857
+ // what it reports back.
2858
+ if (result?.action === 'deleted') {
2859
+ this.selected = null;
2860
+ this.report = null;
2861
+ this._buildReportConfig();
2862
+ this._cdRef.markForCheck();
2863
+ return;
2864
+ }
2865
+ if (result?.action === 'saved' && this.selected) {
2866
+ this._loadReport(this.selected.id);
2867
+ this._refreshReportName(this.selected.id);
2868
+ }
2869
+ });
2870
+ }
2871
+ // Component settings — title, exact geometry, and the diagnostic views
2872
+ // (SQL, config, filter wiring).
2873
+ componentSettings(component) {
2874
+ if (!this.report) {
2875
+ return;
2876
+ }
2877
+ this._dialog.open(ComponentSettingsComponent, {
2878
+ data: { report: this.report, component },
2879
+ width: '640px',
2880
+ })
2881
+ .afterClosed()
2882
+ .pipe(takeUntilDestroyed(this._destroyRef))
2883
+ .subscribe(() => {
2884
+ // Always reload: filter add/remove/level changes persist immediately
2885
+ // inside the dialog (no "save"), so the canvas + report bar must
2886
+ // refresh however the dialog was closed.
2887
+ if (this.selected) {
2888
+ this._loadReport(this.selected.id);
2889
+ }
2890
+ });
2891
+ }
2892
+ // The canvas changed the report's structure (added a page) — re-fetch so the
2893
+ // new page renders; the canvas lands the view on it.
2894
+ onCanvasReportChanged() {
2895
+ if (this.selected) {
2896
+ this._loadReport(this.selected.id);
2897
+ }
2898
+ }
2899
+ // Export the report to PowerPoint, entirely client-side: every component's
2900
+ // data is fetched fresh with the current filter state (all pages, not just
2901
+ // the visible one), then pptxgenjs builds a deck of NATIVE editable charts —
2902
+ // page = slide, positions 1:1. The dock shows progress until the file saves.
2903
+ exportPowerpoint() {
2904
+ if (!this.report) {
2905
+ return;
2906
+ }
2907
+ const report = this.report;
2908
+ this._process.run('Exporting PowerPoint', this._exportCollector.collect(report)
2909
+ .pipe(switchMap((collected) => from(this._pptxService.export(collected)))));
2910
+ }
2911
+ // Export to PDF, entirely client-side: same collection pass, charts rendered
2912
+ // through an offscreen ECharts instance at 3× pixel ratio (pixel-identical
2913
+ // to the screen), tables via jspdf-autotable — page = PDF page.
2914
+ exportPdf() {
2915
+ if (!this.report) {
2916
+ return;
2917
+ }
2918
+ const report = this.report;
2919
+ this._process.run('Exporting PDF', this._exportCollector.collect(report)
2920
+ .pipe(switchMap((collected) => from(this._pdfService.export(collected)))));
2921
+ }
2922
+ // React to each chat turn (fs-chat handles the request + persistence); we
2923
+ // only care about the report side effects this context's backend flags.
2924
+ onChatResponse(response) {
2925
+ // The agent changed the report's structure — re-fetch it. Session filter
2926
+ // values survive (the filter-state service keeps values for groups that
2927
+ // still exist).
2928
+ if (response.reportChanged && this.selected) {
2929
+ this._loadReport(this.selected.id);
2930
+ }
2931
+ // A rename changes nothing structural — refresh the picker chip label.
2932
+ if (response.reportRenamed && this.selected) {
2933
+ this._refreshReportName(this.selected.id);
2934
+ }
2935
+ }
2936
+ // Drag the divider between the chat and canvas panes (pointer-capture loop
2937
+ // outside Angular).
2938
+ onResizeStart(event) {
2939
+ event.preventDefault();
2940
+ const handle = event.target;
2941
+ handle.setPointerCapture(event.pointerId);
2942
+ this._split.nativeElement.classList.add('resizing');
2943
+ this._zone.runOutsideAngular(() => {
2944
+ const move = (e) => this._resizeTo(e.clientX);
2945
+ const end = () => {
2946
+ handle.releasePointerCapture(event.pointerId);
2947
+ handle.removeEventListener('pointermove', move);
2948
+ handle.removeEventListener('pointerup', end);
2949
+ handle.removeEventListener('pointercancel', end);
2950
+ this._split.nativeElement.classList.remove('resizing');
2951
+ };
2952
+ handle.addEventListener('pointermove', move);
2953
+ handle.addEventListener('pointerup', end);
2954
+ handle.addEventListener('pointercancel', end);
2955
+ });
2956
+ }
2957
+ _resizeTo(clientX) {
2958
+ const rect = this._split.nativeElement.getBoundingClientRect();
2959
+ if (rect.width === 0) {
2960
+ return;
2961
+ }
2962
+ const ratio = (clientX - rect.left) / rect.width;
2963
+ const clamped = Math.min(0.8, Math.max(0.2, ratio));
2964
+ this._chatPanel.nativeElement.style.flexBasis = `${clamped * 100}%`;
2965
+ }
2966
+ _loadReport(reportId) {
2967
+ this._reportData.get(reportId)
2968
+ .pipe(takeUntilDestroyed(this._destroyRef))
2969
+ .subscribe((report) => {
2970
+ this.report = report;
2971
+ this._filterState.init(report);
2972
+ this._buildReportConfig();
2973
+ this._cdRef.markForCheck();
2974
+ });
2975
+ }
2976
+ // Report-level filter groups in their saved order — the ones that render in
2977
+ // the toolbar (component-level groups render on their components).
2978
+ _reportGroups() {
2979
+ return (this.report?.filterGroups ?? [])
2980
+ .filter((group) => group.level === 'report' || group.level === 'both')
2981
+ .sort((a, b) => a.order - b.order);
2982
+ }
2983
+ // Whether the loaded report has any report-level filters OR a time-series
2984
+ // chart (which earns the runtime Frequency control) — gates the toolbar
2985
+ // fs-filter so it isn't rendered (taking space) when there's nothing to show.
2986
+ get reportHasFilters() {
2987
+ return this._reportGroups().length > 0 || this._hasTimeSeriesChart();
2988
+ }
2989
+ // fs-filter reads its config only once (at init) — re-binding does nothing.
2990
+ // The template keys the toolbar fs-filter on this signature so it's recreated
2991
+ // (and re-reads the rebuilt config) whenever the report-level filter set
2992
+ // changes, e.g. after the component settings dialog adds/moves a filter.
2993
+ get reportFilterKey() {
2994
+ return [
2995
+ ...this._reportGroups().map((group) => `${group.id}:${group.level}:${group.label}`),
2996
+ this._hasTimeSeriesChart() ? 'frequency' : '',
2997
+ ].join('|');
2998
+ }
2999
+ // (Re)build the toolbar fs-filter config — report-level filter items, plus the
3000
+ // runtime Frequency control when the report has a time-series chart. The
3001
+ // report's actions live in the fs-menu beside the picker, not here. Only
3002
+ // built/used once a report is loaded (the template gates it on `report`).
3003
+ _buildReportConfig() {
3004
+ const reportId = this.report?.id ?? null;
3005
+ const items = reportId
3006
+ ? this._reportGroups().map((group) => filterItemForGroup(group, this._reportData, reportId, this._filterState.value(group.id)))
3007
+ : [];
3008
+ if (reportId && this._hasTimeSeriesChart()) {
3009
+ items.push({
3010
+ name: FREQUENCY_ITEM,
3011
+ type: ItemType.Select,
3012
+ label: 'Frequency',
3013
+ multiple: false,
3014
+ values: () => FREQUENCY_OPTIONS,
3015
+ default: this._filterState.frequency() ?? undefined,
3016
+ });
3017
+ }
3018
+ this.reportFilterConfig = {
3019
+ // Never touch the URL or persist filter state — report filters are
3020
+ // session-only, owned by ReportFilterStateService.
3021
+ queryParam: false,
3022
+ persist: false,
3023
+ items,
3024
+ change: (query) => {
3025
+ for (const { groupId, value } of groupValuesFromQuery(query ?? {}, this._reportGroups())) {
3026
+ this._filterState.setValue(groupId, value);
3027
+ }
3028
+ // Frequency isn't a filter group — it's a render-layer bucket override.
3029
+ this._filterState.setFrequency(query?.[FREQUENCY_ITEM] || null);
3030
+ },
3031
+ };
3032
+ }
3033
+ // The report has at least one time-series chart (a chart whose x-axis is a
3034
+ // time axis) — the only kind the Frequency control affects.
3035
+ _hasTimeSeriesChart() {
3036
+ return (this.report?.pages ?? []).some((page) => page.components.some((component) => component.type === 'chart' && component.config?.xAxis?.kind === 'time'));
3037
+ }
3038
+ _refreshReportName(reportId) {
3039
+ this._reportService.load()
3040
+ .pipe(takeUntilDestroyed(this._destroyRef))
3041
+ .subscribe((reports) => {
3042
+ const renamed = reports.find((report) => report.id === reportId);
3043
+ if (renamed) {
3044
+ this.selected = renamed;
3045
+ if (this.report) {
3046
+ this.report = { ...this.report, name: renamed.name };
3047
+ }
3048
+ this._cdRef.markForCheck();
3049
+ }
3050
+ });
3051
+ }
3052
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3053
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ReportComponent, isStandalone: true, selector: "ng-component", providers: [
3054
+ // Session filter state is per-open-report-page, not app-global; the export
3055
+ // services resolve filters through it, so they live at the same level.
3056
+ ReportFilterStateService,
3057
+ ReportExportCollectorService,
3058
+ ReportPptxService,
3059
+ ReportPdfService,
3060
+ // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
3061
+ // this route's chunk.
3062
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-BxYnpz7n.mjs').then((module) => module.default) }),
3063
+ ], viewQueries: [{ propertyName: "_split", first: true, predicate: ["split"], descendants: true, static: true }, { propertyName: "_chatPanel", first: true, predicate: ["chatPanel"], descendants: true, read: ElementRef, static: 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 <mat-icon>\n {{ editMode ? 'lock' : 'open_with' }}\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 </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: i4$1.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i4$1.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "introMessage"], outputs: ["response"] }, { 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 });
3064
+ }
3065
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponent, decorators: [{
3066
+ type: Component,
3067
+ args: [{ changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, schemas: [CUSTOM_ELEMENTS_SCHEMA], providers: [
3068
+ // Session filter state is per-open-report-page, not app-global; the export
3069
+ // services resolve filters through it, so they live at the same level.
3070
+ ReportFilterStateService,
3071
+ ReportExportCollectorService,
3072
+ ReportPptxService,
3073
+ ReportPdfService,
3074
+ // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
3075
+ // this route's chunk.
3076
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-BxYnpz7n.mjs').then((module) => module.default) }),
3077
+ ], imports: [
3078
+ FormsModule,
3079
+ FsAutocompleteChipsModule,
3080
+ FsFilterModule,
3081
+ FsMenuModule,
3082
+ FsAiChatComponent,
3083
+ MatIcon,
3084
+ ReportCanvasComponent,
3085
+ ], 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 <mat-icon>\n {{ editMode ? 'lock' : 'open_with' }}\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 </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"] }]
3086
+ }], ctorParameters: () => [], propDecorators: { _split: [{
3087
+ type: ViewChild,
3088
+ args: ['split', { static: true }]
3089
+ }], _chatPanel: [{
3090
+ type: ViewChild,
3091
+ args: ['chatPanel', { static: true, read: ElementRef }]
3092
+ }] } });
3093
+
3094
+ /*
3095
+ * Public API Surface of @firestitch/reports
3096
+ *
3097
+ * The AI-built report viewer/editor, its data gateway, session filter state and
3098
+ * renderer components. The host app only needs ReportComponent (route it) —
3099
+ * everything else is internal wiring. The AI chat is provided by @firestitch/ai.
3100
+ */
3101
+
3102
+ /**
3103
+ * Generated bundle index. Do not edit.
3104
+ */
3105
+
3106
+ export { FREQUENCY_OPTIONS as F, REPORT_CHART_COLORS_CSS as R, ReportComponent as a, ReportData as b, ReportService as c, ReportFilterStateService as d };
3107
+ //# sourceMappingURL=firestitch-report-firestitch-report-Cnotycly.mjs.map