@firestitch/report 18.4.0 → 18.5.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.
@@ -979,113 +979,413 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
979
979
  type: Injectable
980
980
  }] });
981
981
 
982
- // colorBy applies only to single-dimension bar charts, and only when splitBy
983
- // isn't already driving multiple series: it colors each bar by its category and
984
- // shows a legend (one series per category, overlapped to full width).
985
- //
986
- // It is the DEFAULT for a categorical bar chart rather than something the config
987
- // has to ask for. A bar chart whose x-axis is a category — provinces, statuses,
988
- // clinics — is comparing those categories to each other, and one flat color with
989
- // no legend is the wrong reading of it in essentially every case. Requiring
990
- // colorBy to opt in meant every such chart rendered monochrome unless whoever
991
- // wrote the config remembered an optional property, which is a default set the
992
- // wrong way round: the common case should not need the flag.
993
- //
994
- // A TIME axis is excluded: consecutive periods are one series moving, not
995
- // categories being compared, and coloring each month differently reads as
996
- // unrelated groups. Explicitly opting out — colorBy: {column: ''} or a
997
- // config.legend of 'hidden' — still wins, so a caller that wants one flat color
998
- // can say so.
999
- //
1000
- // "Bar chart" is the type the lone measure is actually DRAWN as: its own type
1001
- // when it names one, which can turn a line chart's single series into bars or
1002
- // a bar chart's into a line.
1003
- function isColorBy(config) {
1004
- if (config.splitBy?.column || (config.measures?.[0]?.type ?? config.chartType ?? 'bar') !== 'bar') {
1005
- return false;
982
+ // Measure-value formatting shared by the KPI renderer and the exports — the
983
+ // config's `format` (number | percent | currency) and `precision` mean the same
984
+ // thing on screen, in PowerPoint and in PDF.
985
+ // A bare `new Intl.NumberFormat()` defaults to maximumFractionDigits: 3, which
986
+ // is why an unconfigured percentage reads 38.298%. When the measure names a
987
+ // precision it is pinned as BOTH the minimum and the maximum, so a column of
988
+ // rates lines up instead of each value showing however many decimals it happens
989
+ // to need. Undefined leaves the locale default alone, so a report authored
990
+ // before precision existed renders exactly as it did.
991
+ function getFractionDigits(precision) {
992
+ return Number.isFinite(precision)
993
+ ? { minimumFractionDigits: precision, maximumFractionDigits: precision }
994
+ : {};
995
+ }
996
+ function formatMeasureValue(value, format, precision) {
997
+ if (value === null || value === undefined || value === '') {
998
+ return '—';
1006
999
  }
1007
- if (config.colorBy) {
1008
- return !!config.colorBy.column;
1000
+ const numeric = Number(value);
1001
+ if (!Number.isFinite(numeric)) {
1002
+ return String(value);
1003
+ }
1004
+ const fractionDigits = getFractionDigits(precision);
1005
+ switch (format) {
1006
+ case 'percent':
1007
+ return `${new Intl.NumberFormat(undefined, fractionDigits).format(numeric)}%`;
1008
+ case 'currency':
1009
+ return new Intl.NumberFormat(undefined, {
1010
+ style: 'currency',
1011
+ currency: 'USD',
1012
+ // Whole dollars unless the measure asks for decimals — cents were
1013
+ // unrepresentable before, because this was hardcoded to 0.
1014
+ maximumFractionDigits: 0,
1015
+ ...fractionDigits,
1016
+ }).format(numeric);
1017
+ default:
1018
+ return new Intl.NumberFormat(undefined, fractionDigits).format(numeric);
1009
1019
  }
1010
- return (config.xAxis?.kind ?? 'category') === 'category'
1011
- && !config.xAxis?.granularity
1012
- && (config.measures?.length ?? 1) <= 1;
1013
1020
  }
1014
- // The column colorBy groups by — its own when the config named one, otherwise
1015
- // the x-axis it defaults to coloring.
1016
- function colorByColumn(config) {
1017
- return isColorBy(config)
1018
- ? (config.colorBy?.column || config.xAxis?.column)
1019
- : undefined;
1021
+
1022
+ // Text blocks — shared by the canvas and both exports, so a block reads the
1023
+ // same on screen, in the deck and in the PDF.
1024
+ //
1025
+ // A block's html arrives already rebuilt server-side from a small allowlist
1026
+ // (ReportTextHtml): p, h1-h3, strong, em, u, s, ul/ol/li, br, a, and span with
1027
+ // color / background-color / font-size / font-weight / font-family, plus
1028
+ // text-align on a block. Everything here only has to understand that set.
1029
+ // Body text size, points, and the heading sizes — the same numbers the canvas
1030
+ // stylesheet uses, so an unstyled paragraph is one size everywhere.
1031
+ const DEFAULT_TEXT_SIZE = 12;
1032
+ const HEADING_TEXT_SIZES = { 1: 24, 2: 18, 3: 14 };
1033
+ const DEFAULT_TEXT_COLOR = '#1f2933';
1034
+ // The tokens a block may carry, with what fills each. {{page.number}} is the
1035
+ // page's position among the report's pages (1-based), which is what makes a
1036
+ // shared footer number every page correctly.
1037
+ const TOKENS$1 = {
1038
+ 'page.number': (context) => String(context.pageNumber),
1039
+ 'page.name': (context) => context.pageName,
1040
+ 'report.name': (context) => context.reportName,
1041
+ };
1042
+ // Replace every {{token}} in the html. Values are html-escaped: a page named
1043
+ // "R&D <draft>" must read as that, not as markup.
1044
+ function fillTextTokens(html, context) {
1045
+ return html.replace(/\{\{\s*([a-z.]+)\s*\}\}/gi, (match, token) => {
1046
+ const fill = TOKENS$1[token.toLowerCase()];
1047
+ return fill ? escapeHtml(fill(context)) : match;
1048
+ });
1020
1049
  }
1021
- function extractChartSeries(component, data) {
1022
- const config = component.config;
1023
- const xColumn = config.xAxis?.column ?? data.columns[0];
1024
- const splitColumn = config.splitBy?.column;
1025
- // colorBy emits one series per category too (so each bar carries its own
1026
- // color + legend entry), but the bars are overlapped to full width in
1027
- // buildAxisOption rather than laid side-by-side. splitBy wins if both are set.
1028
- const groupColumn = splitColumn || colorByColumn(config);
1029
- const measures = config.measures?.length
1030
- ? config.measures
1031
- : [{ column: data.columns[data.columns.length - 1] }];
1032
- const rawCategories = uniqueValues(data.rows, xColumn);
1033
- const categories = rawCategories.map((category) => String(category ?? '—'));
1034
- const series = [];
1035
- if (groupColumn) {
1036
- // One series per group value (splitBy/colorBy), pivoted over the first measure.
1037
- const measure = measures[0];
1038
- for (const groupValue of uniqueValues(data.rows, groupColumn)) {
1039
- const byCategory = new Map();
1040
- for (const row of data.rows) {
1041
- if (row[groupColumn] === groupValue) {
1042
- byCategory.set(row[xColumn], row[measure.column]);
1043
- }
1050
+ // Walk a block's html into paragraphs of styled runs — the shape both exports
1051
+ // draw from. Tokens are filled first so a page number is just text by then.
1052
+ function textParagraphs(html, context) {
1053
+ const document = new DOMParser().parseFromString(`<body>${fillTextTokens(html, context)}</body>`, 'text/html');
1054
+ const paragraphs = [];
1055
+ walkBlocks(document.body, paragraphs, null);
1056
+ return paragraphs.filter((paragraph, index) =>
1057
+ // Keep an empty paragraph between two others (a deliberate blank line), but
1058
+ // not one left dangling at either end.
1059
+ paragraph.runs.length || (index > 0 && index < paragraphs.length - 1));
1060
+ }
1061
+ // The html as plain text — what a block says, for anything that cannot style it.
1062
+ function textPlain(html, context) {
1063
+ return textParagraphs(html, context)
1064
+ .map((paragraph) => paragraph.runs.map((run) => run.text).join(''))
1065
+ .join('\n');
1066
+ }
1067
+ function walkBlocks(parent, paragraphs, list) {
1068
+ // Loose inline content between blocks (text straight under <body> or <li>)
1069
+ // is gathered into a paragraph of its own.
1070
+ let loose = null;
1071
+ const flush = () => {
1072
+ if (loose) {
1073
+ paragraphs.push(loose);
1074
+ loose = null;
1075
+ }
1076
+ };
1077
+ parent.childNodes.forEach((node) => {
1078
+ const name = node.nodeName.toLowerCase();
1079
+ if (name === 'ul' || name === 'ol') {
1080
+ flush();
1081
+ walkBlocks(node, paragraphs, name === 'ul' ? 'bullet' : 'number');
1082
+ return;
1083
+ }
1084
+ if (name === 'li') {
1085
+ flush();
1086
+ const item = { runs: [], bullet: list ?? 'bullet' };
1087
+ const nested = [];
1088
+ // A list item's first paragraph IS the item; anything after it is its own
1089
+ // line under the same bullet.
1090
+ walkBlocks(node, nested, list);
1091
+ const [first, ...rest] = nested.length ? nested : [item];
1092
+ paragraphs.push({ ...first, bullet: list ?? 'bullet' }, ...rest);
1093
+ return;
1094
+ }
1095
+ if (name === 'p' || /^h[1-3]$/.test(name)) {
1096
+ flush();
1097
+ const element = node;
1098
+ const heading = name.startsWith('h') ? Number(name[1]) : undefined;
1099
+ const paragraph = {
1100
+ runs: [],
1101
+ align: blockAlign(element),
1102
+ bullet: undefined,
1103
+ heading,
1104
+ };
1105
+ const base = heading
1106
+ ? { bold: true, fontSize: HEADING_TEXT_SIZES[heading] }
1107
+ : {};
1108
+ walkInline(node, base, paragraph, paragraphs);
1109
+ paragraphs.push(paragraph);
1110
+ return;
1111
+ }
1112
+ loose = loose ?? { runs: [] };
1113
+ walkInline(node, {}, loose, paragraphs, true);
1114
+ });
1115
+ flush();
1116
+ }
1117
+ // Accumulate inline styles down the tree. A <br> ends the current line: the
1118
+ // runs after it start a new paragraph with the same alignment and bullet.
1119
+ function walkInline(node, style, paragraph, paragraphs, isSelf = false) {
1120
+ const visit = (child) => {
1121
+ if (child.nodeType === Node.TEXT_NODE) {
1122
+ const text = child.textContent ?? '';
1123
+ if (text) {
1124
+ paragraph.runs.push({ ...style, text });
1044
1125
  }
1045
- series.push({
1046
- name: String(groupValue ?? '—'),
1047
- values: rawCategories.map((category) => numeric(byCategory.get(category))),
1048
- type: measure.type,
1049
- });
1126
+ return;
1127
+ }
1128
+ if (child.nodeType !== Node.ELEMENT_NODE) {
1129
+ return;
1130
+ }
1131
+ const element = child;
1132
+ const name = element.nodeName.toLowerCase();
1133
+ if (name === 'br') {
1134
+ paragraphs.push({ ...paragraph, runs: [...paragraph.runs] });
1135
+ paragraph.runs.length = 0;
1136
+ return;
1050
1137
  }
1138
+ walkInline(element, { ...style, ...inlineStyle(element) }, paragraph, paragraphs);
1139
+ };
1140
+ if (isSelf) {
1141
+ visit(node);
1051
1142
  }
1052
1143
  else {
1053
- for (const measure of measures) {
1054
- const byCategory = new Map();
1055
- for (const row of data.rows) {
1056
- byCategory.set(row[xColumn], row[measure.column]);
1057
- }
1058
- series.push({
1059
- name: measure.label ?? measure.column,
1060
- values: rawCategories.map((category) => numeric(byCategory.get(category))),
1061
- type: measure.type,
1062
- });
1063
- }
1144
+ node.childNodes.forEach(visit);
1064
1145
  }
1065
- return { categories, series };
1066
- }
1067
- // The type each extracted series is drawn as: its measure's own type when it
1068
- // names one, the chart's chartType otherwise. More than one distinct type is a
1069
- // combo chart. Shared with the PowerPoint export so both media draw each series
1070
- // the same way.
1071
- function seriesTypes(extracted, chartType) {
1072
- return extracted.series.map((series) => series.type ?? chartType);
1073
1146
  }
1074
- // The one pure mapping from our standard camelCase chart config + the data
1075
- // endpoint's rows to an ECharts option. Everything visual that isn't in the
1076
- // registered 'report' theme is decided here, so report standards have exactly
1077
- // two homes.
1078
- function buildChartOption(component, data) {
1079
- const config = component.config;
1080
- const chartType = config.chartType ?? 'bar';
1081
- if (chartType === 'pie' || chartType === 'donut') {
1082
- return buildPieOption(component, data, chartType === 'donut');
1147
+ function inlineStyle(element) {
1148
+ const name = element.nodeName.toLowerCase();
1149
+ const style = {};
1150
+ if (name === 'strong') {
1151
+ style.bold = true;
1083
1152
  }
1084
- return buildAxisOption(component, data, chartType);
1085
- }
1086
- function buildAxisOption(component, data, chartType) {
1087
- const config = component.config;
1088
- const extracted = extractChartSeries(component, data);
1153
+ if (name === 'em') {
1154
+ style.italic = true;
1155
+ }
1156
+ if (name === 'u') {
1157
+ style.underline = true;
1158
+ }
1159
+ if (name === 's') {
1160
+ style.strike = true;
1161
+ }
1162
+ if (name === 'a') {
1163
+ style.link = element.getAttribute('href') ?? undefined;
1164
+ style.underline = true;
1165
+ }
1166
+ const css = element.style;
1167
+ if (css.color) {
1168
+ style.color = hexColor(css.color);
1169
+ }
1170
+ if (css.backgroundColor) {
1171
+ style.highlight = hexColor(css.backgroundColor);
1172
+ }
1173
+ if (css.fontSize) {
1174
+ style.fontSize = points(css.fontSize);
1175
+ }
1176
+ if (css.fontWeight) {
1177
+ style.bold = css.fontWeight === 'bold' || Number(css.fontWeight) >= 600;
1178
+ }
1179
+ if (css.fontStyle) {
1180
+ style.italic = css.fontStyle === 'italic';
1181
+ }
1182
+ if (css.textDecoration) {
1183
+ style.underline = css.textDecoration.includes('underline');
1184
+ style.strike = css.textDecoration.includes('line-through');
1185
+ }
1186
+ if (css.fontFamily) {
1187
+ style.fontFace = css.fontFamily.split(',')[0].replace(/["']/g, '').trim();
1188
+ }
1189
+ return style;
1190
+ }
1191
+ function blockAlign(element) {
1192
+ const align = element.style.textAlign;
1193
+ return align === 'center' || align === 'right' || align === 'justify' ? align : undefined;
1194
+ }
1195
+ // A CSS font-size in points. The canvas renders at 96px per inch, so 1px is
1196
+ // exactly 0.75pt; em/rem/% are relative to the body size.
1197
+ function points(value) {
1198
+ const match = /^([\d.]+)(pt|px|em|rem|%)$/.exec(value.trim());
1199
+ if (!match) {
1200
+ return undefined;
1201
+ }
1202
+ const size = Number(match[1]);
1203
+ switch (match[2]) {
1204
+ case 'pt':
1205
+ return size;
1206
+ case 'px':
1207
+ return size * 0.75;
1208
+ case '%':
1209
+ return (size / 100) * DEFAULT_TEXT_SIZE;
1210
+ default:
1211
+ return size * DEFAULT_TEXT_SIZE;
1212
+ }
1213
+ }
1214
+ // Any CSS color the allowlist lets through (hex, rgb(), a name) as #rrggbb —
1215
+ // the one form both exports accept. The browser resolves it, so a named color
1216
+ // comes out exactly as the canvas painted it.
1217
+ let colorContext = null;
1218
+ function hexColor(value) {
1219
+ colorContext = colorContext ?? document.createElement('canvas').getContext('2d');
1220
+ if (!colorContext) {
1221
+ return undefined;
1222
+ }
1223
+ colorContext.fillStyle = '#000000';
1224
+ colorContext.fillStyle = value;
1225
+ const resolved = String(colorContext.fillStyle);
1226
+ if (resolved.startsWith('#')) {
1227
+ return resolved;
1228
+ }
1229
+ // rgba(...) for a translucent color — drop the alpha, keep the hue.
1230
+ const channels = resolved.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [];
1231
+ return channels.length === 3
1232
+ ? `#${channels.map((channel) => Math.round(channel).toString(16).padStart(2, '0')).join('')}`
1233
+ : undefined;
1234
+ }
1235
+ function escapeHtml(value) {
1236
+ return value
1237
+ .replace(/&/g, '&amp;')
1238
+ .replace(/</g, '&lt;')
1239
+ .replace(/>/g, '&gt;')
1240
+ .replace(/"/g, '&quot;');
1241
+ }
1242
+
1243
+ // The report chart palette — one definition shared by the ECharts theme (on
1244
+ // screen), the PowerPoint export's native chart colors, and the PDF export,
1245
+ // so a series keeps its color across every medium.
1246
+ const REPORT_CHART_COLORS = [
1247
+ '4e79a7', 'f28e2b', 'e15759', '76b7b2', '59a14f',
1248
+ 'edc948', 'b07aa1', 'ff9da7', '9c755f', 'bab0ac',
1249
+ ];
1250
+ // CSS-hex form for ECharts.
1251
+ const REPORT_CHART_COLORS_CSS = REPORT_CHART_COLORS.map((color) => `#${color}`);
1252
+ // A waterfall colors each bar by what it does to the running total, in the
1253
+ // order Excel and PowerPoint use by default: a rise takes the first series
1254
+ // color, a fall the second, and the closing total a neutral gray.
1255
+ const REPORT_WATERFALL_COLORS = {
1256
+ rise: REPORT_CHART_COLORS[0],
1257
+ fall: REPORT_CHART_COLORS[1],
1258
+ total: REPORT_CHART_COLORS[9],
1259
+ };
1260
+
1261
+ // colorBy applies only to single-dimension bar charts, and only when splitBy
1262
+ // isn't already driving multiple series: it colors each bar by its category and
1263
+ // shows a legend (one series per category, overlapped to full width).
1264
+ //
1265
+ // It is the DEFAULT for a categorical bar chart rather than something the config
1266
+ // has to ask for. A bar chart whose x-axis is a category — provinces, statuses,
1267
+ // clinics — is comparing those categories to each other, and one flat color with
1268
+ // no legend is the wrong reading of it in essentially every case. Requiring
1269
+ // colorBy to opt in meant every such chart rendered monochrome unless whoever
1270
+ // wrote the config remembered an optional property, which is a default set the
1271
+ // wrong way round: the common case should not need the flag.
1272
+ //
1273
+ // A TIME axis is excluded: consecutive periods are one series moving, not
1274
+ // categories being compared, and coloring each month differently reads as
1275
+ // unrelated groups. Explicitly opting out — colorBy: {column: ''} or a
1276
+ // config.legend of 'hidden' — still wins, so a caller that wants one flat color
1277
+ // can say so.
1278
+ //
1279
+ // "Bar chart" is the type the lone measure is actually DRAWN as: its own type
1280
+ // when it names one, which can turn a line chart's single series into bars or
1281
+ // a bar chart's into a line.
1282
+ function isColorBy(config) {
1283
+ if (config.splitBy?.column || (config.measures?.[0]?.type ?? config.chartType ?? 'bar') !== 'bar') {
1284
+ return false;
1285
+ }
1286
+ if (config.colorBy) {
1287
+ return !!config.colorBy.column;
1288
+ }
1289
+ return (config.xAxis?.kind ?? 'category') === 'category'
1290
+ && !config.xAxis?.granularity
1291
+ && (config.measures?.length ?? 1) <= 1;
1292
+ }
1293
+ // The column colorBy groups by — its own when the config named one, otherwise
1294
+ // the x-axis it defaults to coloring.
1295
+ function colorByColumn(config) {
1296
+ return isColorBy(config)
1297
+ ? (config.colorBy?.column || config.xAxis?.column)
1298
+ : undefined;
1299
+ }
1300
+ function extractChartSeries(component, data) {
1301
+ const config = component.config;
1302
+ const xColumn = config.xAxis?.column ?? data.columns[0];
1303
+ const splitColumn = config.splitBy?.column;
1304
+ // colorBy emits one series per category too (so each bar carries its own
1305
+ // color + legend entry), but the bars are overlapped to full width in
1306
+ // buildAxisOption rather than laid side-by-side. splitBy wins if both are set.
1307
+ const groupColumn = splitColumn || colorByColumn(config);
1308
+ const measures = config.measures?.length
1309
+ ? config.measures
1310
+ : [{ column: data.columns[data.columns.length - 1] }];
1311
+ const rawCategories = uniqueValues(data.rows, xColumn);
1312
+ const categories = rawCategories.map((category) => String(category ?? '—'));
1313
+ const series = [];
1314
+ if (groupColumn) {
1315
+ // One series per group value (splitBy/colorBy), pivoted over the first measure.
1316
+ const measure = measures[0];
1317
+ for (const groupValue of uniqueValues(data.rows, groupColumn)) {
1318
+ const byCategory = new Map();
1319
+ for (const row of data.rows) {
1320
+ if (row[groupColumn] === groupValue) {
1321
+ byCategory.set(row[xColumn], row[measure.column]);
1322
+ }
1323
+ }
1324
+ series.push({
1325
+ name: String(groupValue ?? '—'),
1326
+ values: rawCategories.map((category) => numeric(byCategory.get(category))),
1327
+ type: measure.type,
1328
+ });
1329
+ }
1330
+ }
1331
+ else {
1332
+ for (const measure of measures) {
1333
+ const byCategory = new Map();
1334
+ for (const row of data.rows) {
1335
+ byCategory.set(row[xColumn], row[measure.column]);
1336
+ }
1337
+ series.push({
1338
+ name: measure.label ?? measure.column,
1339
+ values: rawCategories.map((category) => numeric(byCategory.get(category))),
1340
+ type: measure.type,
1341
+ });
1342
+ }
1343
+ }
1344
+ return { categories, series };
1345
+ }
1346
+ // The type each extracted series is drawn as: its measure's own type when it
1347
+ // names one, the chart's chartType otherwise. More than one distinct type is a
1348
+ // combo chart. Shared with the PowerPoint export so both media draw each series
1349
+ // the same way.
1350
+ function seriesTypes(extracted, chartType) {
1351
+ return extracted.series.map((series) => series.type ?? chartType);
1352
+ }
1353
+ const WATERFALL_TOTAL_LABEL = 'Total';
1354
+ // A waterfall's bars from its one measure: each category's value is how far the
1355
+ // running total moves there, and a last Total bar shows where it ended. A
1356
+ // category with no value moves nothing and draws no bar. Shared with the
1357
+ // PowerPoint export so both media draw the same steps.
1358
+ function extractWaterfall(component, data) {
1359
+ const extracted = extractChartSeries(component, data);
1360
+ const values = extracted.series[0]?.values ?? [];
1361
+ const steps = [];
1362
+ let total = 0;
1363
+ extracted.categories.forEach((category, index) => {
1364
+ const change = values[index] ?? null;
1365
+ steps.push({ category, start: total, change, kind: (change ?? 0) < 0 ? 'fall' : 'rise' });
1366
+ total += change ?? 0;
1367
+ });
1368
+ steps.push({ category: WATERFALL_TOTAL_LABEL, start: 0, change: total, kind: 'total' });
1369
+ return { name: extracted.series[0]?.name ?? '', steps };
1370
+ }
1371
+ // The one pure mapping from our standard camelCase chart config + the data
1372
+ // endpoint's rows to an ECharts option. Everything visual that isn't in the
1373
+ // registered 'report' theme is decided here, so report standards have exactly
1374
+ // two homes.
1375
+ function buildChartOption(component, data) {
1376
+ const config = component.config;
1377
+ const chartType = config.chartType ?? 'bar';
1378
+ if (chartType === 'pie' || chartType === 'donut') {
1379
+ return buildPieOption(component, data, chartType === 'donut');
1380
+ }
1381
+ if (chartType === 'waterfall') {
1382
+ return buildWaterfallOption(component, data);
1383
+ }
1384
+ return buildAxisOption(component, data, chartType);
1385
+ }
1386
+ function buildAxisOption(component, data, chartType) {
1387
+ const config = component.config;
1388
+ const extracted = extractChartSeries(component, data);
1089
1389
  const horizontal = config.orientation === 'horizontal';
1090
1390
  const colorBy = isColorBy(config);
1091
1391
  const types = seriesTypes(extracted, chartType);
@@ -1099,15 +1399,7 @@ function buildAxisOption(component, data, chartType) {
1099
1399
  // a combo each type stacks only with its own kind, so a line of a rate never
1100
1400
  // sits on top of the bars' total.
1101
1401
  const series = extracted.series.map((extractedSeries, index) => buildSeries(extractedSeries.name, extractedSeries.values, types[index], config.stacked ? (combo ? `total-${types[index]}` : 'total') : undefined, colorBy && index === 0, combo));
1102
- const granularity = bucketGranularity(data);
1103
- const categoryAxis = {
1104
- type: 'category',
1105
- data: extracted.categories,
1106
- name: config.xAxis?.label,
1107
- nameLocation: 'middle',
1108
- nameGap: granularity && spansMultipleYears(extracted.categories) ? 40 : 28,
1109
- ...periodAxisLabel(granularity, extracted.categories),
1110
- };
1402
+ const categoryAxis = categoryAxisOption(config, data, extracted.categories);
1111
1403
  const valueAxis = { type: 'value' };
1112
1404
  const legendPosition = resolveLegendPosition(config, series.length, colorBy);
1113
1405
  return {
@@ -1121,6 +1413,87 @@ function buildAxisOption(component, data, chartType) {
1121
1413
  series,
1122
1414
  };
1123
1415
  }
1416
+ // The category axis of an axis chart or a waterfall: the categories in row
1417
+ // order, labeled as periods when the rows were bucketed by time.
1418
+ function categoryAxisOption(config, data, categories) {
1419
+ const granularity = bucketGranularity(data);
1420
+ return {
1421
+ type: 'category',
1422
+ data: categories,
1423
+ name: config.xAxis?.label,
1424
+ nameLocation: 'middle',
1425
+ nameGap: granularity && spansMultipleYears(categories) ? 40 : 28,
1426
+ ...periodAxisLabel(granularity, categories),
1427
+ };
1428
+ }
1429
+ function buildWaterfallOption(component, data) {
1430
+ const config = component.config;
1431
+ const waterfall = extractWaterfall(component, data);
1432
+ const categoryAxis = categoryAxisOption(config, data, waterfall.steps.map((step) => step.category));
1433
+ const valueAxis = { type: 'value' };
1434
+ const horizontal = config.orientation === 'horizontal';
1435
+ // One visible series, so the legend is hidden unless the config pins it.
1436
+ const legendPosition = resolveLegendPosition(config, 1);
1437
+ const granularity = bucketGranularity(data);
1438
+ return {
1439
+ grid: axisGrid(legendPosition),
1440
+ legend: { ...legendOption(legendPosition), data: [waterfall.name] },
1441
+ // Per bar: the invisible base is silent, so only a step answers the hover.
1442
+ tooltip: {
1443
+ trigger: 'item',
1444
+ formatter: (params) => waterfallTooltip(waterfall, (Array.isArray(params) ? params[0] : params).dataIndex, granularity),
1445
+ },
1446
+ xAxis: horizontal ? valueAxis : categoryAxis,
1447
+ yAxis: horizontal ? categoryAxis : valueAxis,
1448
+ series: waterfallSeries(waterfall),
1449
+ };
1450
+ }
1451
+ // An invisible base series lifts each visible bar to where its step starts.
1452
+ // Both stack with the 'all' strategy, which adds signed values in series order,
1453
+ // so a fall draws DOWN from the total before it, even across zero. The default
1454
+ // same-sign stacking would hang a negative bar below the axis instead.
1455
+ function waterfallSeries(waterfall) {
1456
+ return [
1457
+ {
1458
+ type: 'bar',
1459
+ stack: 'waterfall',
1460
+ stackStrategy: 'all',
1461
+ data: waterfall.steps.map((step) => step.start),
1462
+ barMaxWidth: 48,
1463
+ itemStyle: { color: 'transparent' },
1464
+ silent: true,
1465
+ },
1466
+ {
1467
+ name: waterfall.name,
1468
+ type: 'bar',
1469
+ stack: 'waterfall',
1470
+ stackStrategy: 'all',
1471
+ barMaxWidth: 48,
1472
+ // The series color is the rise color so a pinned legend shows it; each
1473
+ // bar is then colored by what it does to the total.
1474
+ itemStyle: { color: `#${REPORT_WATERFALL_COLORS.rise}` },
1475
+ data: waterfall.steps.map((step) => ({
1476
+ value: step.change,
1477
+ itemStyle: { color: `#${REPORT_WATERFALL_COLORS[step.kind]}` },
1478
+ })),
1479
+ },
1480
+ ];
1481
+ }
1482
+ // A step's tooltip: its period, how far it moved the running total and where
1483
+ // that left it. The closing Total shows the total alone. The category and the
1484
+ // measure name come from the data and the config, and ECharts sets the tooltip
1485
+ // as HTML, so both are escaped.
1486
+ function waterfallTooltip(waterfall, index, granularity) {
1487
+ const step = waterfall.steps[index];
1488
+ const category = escapeHtml(granularity ? formatPeriodLabel(step.category, granularity, true) : step.category);
1489
+ const name = escapeHtml(waterfall.name);
1490
+ if (step.kind === 'total') {
1491
+ return `${category}<br/>${name}: ${formatMeasureValue(step.change)}`;
1492
+ }
1493
+ const sign = (step.change ?? 0) > 0 ? '+' : '';
1494
+ return `${category}<br/>${name}: ${sign}${formatMeasureValue(step.change)}`
1495
+ + `<br/>Running total: ${formatMeasureValue(step.start + (step.change ?? 0))}`;
1496
+ }
1124
1497
  // Default legend placement when the config doesn't pin one: pie charts read
1125
1498
  // best with the slice key beside them (right); axis charts only need a legend
1126
1499
  // to tell multiple series apart, so a single-series chart hides it. An explicit
@@ -1236,670 +1609,409 @@ function uniqueValues(rows, column) {
1236
1609
  const values = [];
1237
1610
  for (const row of rows) {
1238
1611
  const value = row[column];
1239
- if (!seen.has(value)) {
1240
- seen.add(value);
1241
- values.push(value);
1242
- }
1243
- }
1244
- return values;
1245
- }
1246
- function numeric(value) {
1247
- if (value === null || value === undefined || value === '') {
1248
- return null;
1249
- }
1250
- const parsed = Number(value);
1251
- return Number.isFinite(parsed) ? parsed : null;
1252
- }
1253
- // The effective time bucket the rows were grouped into (from the data endpoint),
1254
- // or null when the x-axis isn't a time axis — gates the compact period labels.
1255
- function bucketGranularity(data) {
1256
- return data.granularity && GRANULARITY_OPTIONS.some((option) => option.value === data.granularity)
1257
- ? data.granularity
1258
- : null;
1259
- }
1260
- // Whether the period-start categories cross a year boundary. When they don't,
1261
- // the year is pure noise on every label, so it's dropped entirely.
1262
- function spansMultipleYears(categories) {
1263
- const years = new Set();
1264
- for (const category of categories) {
1265
- const date = parseISO(category);
1266
- if (isValid(date)) {
1267
- years.add(date.getFullYear());
1268
- }
1269
- }
1270
- return years.size > 1;
1271
- }
1272
- // A bucket's period-start split into its primary label and its year. Day/week
1273
- // read as "May 1", month as "May", quarter as "Q2", year as "2025". Returns null
1274
- // for a value that isn't a parseable date (left as-is by callers).
1275
- function periodLabelParts(value, granularity) {
1276
- const date = parseISO(value);
1277
- if (!isValid(date)) {
1278
- return null;
1279
- }
1280
- const year = String(date.getFullYear());
1281
- switch (granularity) {
1282
- case 'year':
1283
- return { primary: year, year };
1284
- case 'quarter':
1285
- return { primary: `Q${Math.floor(date.getMonth() / 3) + 1}`, year };
1286
- case 'month':
1287
- return { primary: format(date, 'MMMM'), year };
1288
- default: // day, week — the bucket start as a compact "MMM d"
1289
- return { primary: format(date, 'MMM d'), year };
1290
- }
1291
- }
1292
- // A compact single-line period label (year appended only when the data spans
1293
- // years) — for media that can't do a styled second line, e.g. PowerPoint.
1294
- function formatPeriodLabel(value, granularity, multiYear) {
1295
- const parts = periodLabelParts(value, granularity);
1296
- if (!parts) {
1297
- return value;
1298
- }
1299
- return multiYear && granularity !== 'year' ? `${parts.primary} ${parts.year}` : parts.primary;
1300
- }
1301
- // ECharts category-axis label config for a time bucket: the primary label on
1302
- // line one, and — only when the data spans multiple years — the year on a small
1303
- // gray second line, centered under the tick (year granularity needs no second
1304
- // line, it already is the year). Empty for a non-time axis (labels as-is).
1305
- function periodAxisLabel(granularity, categories) {
1306
- if (!granularity) {
1307
- return {};
1308
- }
1309
- const multiYear = spansMultipleYears(categories);
1310
- return {
1311
- axisLabel: {
1312
- formatter: (value) => {
1313
- const parts = periodLabelParts(value, granularity);
1314
- if (!parts) {
1315
- return value;
1316
- }
1317
- return multiYear && granularity !== 'year'
1318
- ? `${parts.primary}\n{yr|${parts.year}}`
1319
- : parts.primary;
1320
- },
1321
- rich: { yr: { fontSize: 10, color: '#9aa0a6', lineHeight: 14 } },
1322
- },
1323
- };
1324
- }
1325
-
1326
- // ECharts host for a chart component. Pure rendering: the parent owns the
1327
- // data fetch; this maps (component config, rows) → option and exposes the
1328
- // chart instance for resize + the future image exports (getDataURL needs the
1329
- // canvas renderer registered in theme/echarts.ts).
1330
- class ComponentChartComponent {
1331
- component;
1332
- data = null;
1333
- options = null;
1334
- _chart = null;
1335
- ngOnChanges() {
1336
- this.options = this.component && this.data
1337
- ? buildChartOption(this.component, this.data)
1338
- : null;
1339
- }
1340
- onChartInit(chart) {
1341
- this._chart = chart;
1342
- }
1343
- // Called by the canvas when the box is resized in edit mode.
1344
- resize() {
1345
- this._chart?.resize();
1346
- }
1347
- // High-res image for the (later-phase) PPT/PDF exports.
1348
- getDataURL(pixelRatio = 3) {
1349
- return this._chart?.getDataURL({
1350
- type: 'png',
1351
- pixelRatio,
1352
- backgroundColor: '#ffffff',
1353
- }) ?? null;
1354
- }
1355
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentChartComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1356
- 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 });
1357
- }
1358
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentChartComponent, decorators: [{
1359
- type: Component,
1360
- args: [{ selector: 'app-report-component-chart', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
1361
- NgxEchartsDirective,
1362
- ], 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"] }]
1363
- }], propDecorators: { component: [{
1364
- type: Input
1365
- }], data: [{
1366
- type: Input
1367
- }] } });
1368
-
1369
- // Measure-value formatting shared by the KPI renderer and the exports — the
1370
- // config's `format` (number | percent | currency) and `precision` mean the same
1371
- // thing on screen, in PowerPoint and in PDF.
1372
- // A bare `new Intl.NumberFormat()` defaults to maximumFractionDigits: 3, which
1373
- // is why an unconfigured percentage reads 38.298%. When the measure names a
1374
- // precision it is pinned as BOTH the minimum and the maximum, so a column of
1375
- // rates lines up instead of each value showing however many decimals it happens
1376
- // to need. Undefined leaves the locale default alone, so a report authored
1377
- // before precision existed renders exactly as it did.
1378
- function getFractionDigits(precision) {
1379
- return Number.isFinite(precision)
1380
- ? { minimumFractionDigits: precision, maximumFractionDigits: precision }
1381
- : {};
1382
- }
1383
- function formatMeasureValue(value, format, precision) {
1384
- if (value === null || value === undefined || value === '') {
1385
- return '—';
1386
- }
1387
- const numeric = Number(value);
1388
- if (!Number.isFinite(numeric)) {
1389
- return String(value);
1390
- }
1391
- const fractionDigits = getFractionDigits(precision);
1392
- switch (format) {
1393
- case 'percent':
1394
- return `${new Intl.NumberFormat(undefined, fractionDigits).format(numeric)}%`;
1395
- case 'currency':
1396
- return new Intl.NumberFormat(undefined, {
1397
- style: 'currency',
1398
- currency: 'USD',
1399
- // Whole dollars unless the measure asks for decimals — cents were
1400
- // unrepresentable before, because this was hardcoded to 0.
1401
- maximumFractionDigits: 0,
1402
- ...fractionDigits,
1403
- }).format(numeric);
1404
- default:
1405
- return new Intl.NumberFormat(undefined, fractionDigits).format(numeric);
1406
- }
1407
- }
1408
-
1409
- // A single big number: the component's measure aggregated over all (filtered)
1410
- // rows server-side; this just formats the one cell.
1411
- class ComponentKpiComponent {
1412
- component;
1413
- data = null;
1414
- value = '—';
1415
- label = '';
1416
- ngOnChanges() {
1417
- const config = (this.component?.config ?? {});
1418
- this.label = config.measure?.label ?? '';
1419
- const row = this.data?.rows?.[0];
1420
- if (!row) {
1421
- this.value = '—';
1422
- return;
1423
- }
1424
- const column = config.measure?.column ?? Object.keys(row)[0];
1425
- this.value = formatMeasureValue(row[column], config.measure?.format, config.measure?.precision);
1426
- }
1427
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentKpiComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1428
- 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 });
1429
- }
1430
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentKpiComponent, decorators: [{
1431
- type: Component,
1432
- 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"] }]
1433
- }], propDecorators: { component: [{
1434
- type: Input
1435
- }], data: [{
1436
- type: Input
1437
- }] } });
1438
-
1439
- // A list component: FsList + FsFilter rendered from the component's config and
1440
- // its report_filters rows. The component's own filter bar carries its filters
1441
- // whose group renders at the component level; report-level groups apply
1442
- // through the shared filter-state service and merge into every request.
1443
- // Paging and sorting are pushed into the SQL wrap server-side.
1444
- class ComponentListComponent {
1445
- reportId;
1446
- component;
1447
- groups;
1448
- listComponent;
1449
- listConfig;
1450
- // A plain list's columns are authored and fixed at init. A cross-tab's are
1451
- // the pivot dimension's VALUES, so they arrive with the rows and change
1452
- // whenever the filters do — which is why this is a signal rather than the
1453
- // one-shot read it used to be.
1454
- columns = signal([]);
1455
- // A cross-tab sorts only by its row-key column: every other header is a
1456
- // pivot value, and the server orders the long rows before pivoting, so a
1457
- // click on "Mar 2026" has nothing to order by.
1458
- pivoted = signal(false);
1459
- // A cut result is worse for a grid than for a list: the cut lands on the LONG
1460
- // rows before the reshape, so a pivot value living only in the tail vanishes
1461
- // as a whole COLUMN and straddled cells come back blank, indistinguishable
1462
- // from a real zero. The parent's truncation banner never fires for a list
1463
- // (it only renders what _load() fetched, which lists skip), so the warning
1464
- // belongs here.
1465
- truncated = signal(false);
1466
- _reportData = inject(ReportData);
1467
- _filterState = inject(ReportFilterStateService);
1468
- _destroyRef = inject(DestroyRef);
1469
- ngOnInit() {
1470
- const config = (this.component.config ?? {});
1471
- this.pivoted.set(!!config.pivotBy?.column);
1472
- this.columns.set(config.columns ?? []);
1473
- this._initListConfig(config);
1474
- // Report-level filter changes that scope this component re-run the list.
1475
- this._filterState.changesFor(this.component)
1476
- .pipe(takeUntilDestroyed(this._destroyRef))
1477
- .subscribe(() => this.listComponent?.reload());
1478
- }
1479
- _initListConfig(config) {
1480
- this.listConfig = {
1481
- // Inside a report component the list is a clean data pane — no status
1482
- // bar, no reload button (filter changes already refetch).
1483
- status: false,
1484
- reload: false,
1485
- // Never touch the URL or persist filter/paging state — report filtering
1486
- // is session-only.
1487
- queryParam: false,
1488
- persist: false,
1489
- sort: config.sort
1490
- ? { value: config.sort.column, direction: config.sort.direction ?? 'asc' }
1491
- : undefined,
1492
- // Load-more: the list keeps a fixed, scrollable height and appends the
1493
- // next page in place instead of swapping pages. The button is pinned to
1494
- // the bottom of the scroll pane (see component SCSS).
1495
- //
1496
- // Uses the Offset strategy (offset + limit) rather than Many: in
1497
- // @firestitch/list the pagination template resolves `hasManyStrategy`
1498
- // BEFORE `loadMoreEnabled`, and Many renders a forward-only page pager —
1499
- // so Many would suppress the load-more button entirely. Offset + loadMore
1500
- // is the combination that actually renders and appends. `_fetch` already
1501
- // derives `page` from `offset`, so the server-side wrap is unaffected.
1502
- paging: {
1503
- limit: config.pageSize ?? 25,
1504
- strategy: PaginationStrategy.Offset,
1505
- },
1506
- loadMore: {
1507
- enabled: true,
1508
- label: 'Load more',
1509
- buttonType: 'stroked',
1510
- },
1511
- filters: this._ownFilters().map((filter) => this._filterItem(filter)),
1512
- fetch: (query) => this._fetch(query),
1513
- };
1514
- }
1515
- // The filters this list renders in its own bar: its report_filters whose
1516
- // group is component-level. Report-level groups render in the report bar.
1517
- _ownFilters() {
1518
- return (this.component.filters ?? [])
1519
- .filter((filter) => {
1520
- const group = this.groups?.get(filter.filterGroupId);
1521
- return filter.enabled && (!group || group.level === 'component' || group.level === 'both');
1522
- });
1523
- }
1524
- _filterItem(filter) {
1525
- const group = this.groups?.get(filter.filterGroupId);
1526
- const name = `f${filter.id}`;
1527
- const label = filter.label || group?.label || filter.filterColumn;
1528
- switch (group?.type) {
1529
- case 'dateRange':
1530
- return {
1531
- name,
1532
- type: ItemType.DateRange,
1533
- label: [`${label} From`, `${label} To`],
1534
- };
1535
- case 'select':
1536
- return {
1537
- name,
1538
- type: ItemType.AutoCompleteChips,
1539
- label,
1540
- // The server sends { value, label }: the filter matches on the value
1541
- // (an entity's stable id) while the viewer reads the label.
1542
- values: () => this._reportData.filterOptions(this.reportId, filter.id)
1543
- .pipe(map$1((options) => (options ?? [])
1544
- .map((option) => ({ name: option.label, value: option.value })))),
1545
- };
1546
- case 'keyword':
1547
- default:
1548
- return {
1549
- name,
1550
- type: ItemType.Keyword,
1551
- label,
1552
- };
1553
- }
1554
- }
1555
- _fetch(query) {
1556
- // Many/load-more sends `offset` + `limit` (not `page`); derive the page so
1557
- // the server-side wrap keeps working unchanged. Page strategy still sends
1558
- // `page` directly.
1559
- const pageSize = query.limit ?? 25;
1560
- const page = query.page
1561
- ?? (query.offset != null ? Math.floor(query.offset / pageSize) + 1 : 1);
1562
- const state = {
1563
- page,
1564
- pageSize,
1565
- };
1566
- const order = String(query.order ?? '');
1567
- if (order) {
1568
- const [column, direction] = order.split(',');
1569
- state.sort = { column, direction: direction === 'desc' ? 'desc' : 'asc' };
1570
- }
1571
- // Report-level values from the shared session state, merged with this
1572
- // list's own filter-bar values (own values win per filter).
1573
- const resolved = new Map();
1574
- for (const value of this._filterState.resolveForComponent(this.component)) {
1575
- resolved.set(value.filterId, value);
1576
- }
1577
- for (const value of this._barValues(query)) {
1578
- resolved.set(value.filterId, value);
1579
- }
1580
- return this._reportData
1581
- .componentData(this.reportId, this.component.id, [...resolved.values()], state)
1582
- .pipe(map$1((data) => {
1583
- // A cross-tab's headers come back with the rows: the row-key column
1584
- // first, then one per pivot value. Re-read them on every fetch, since
1585
- // a filter change can add or remove a column.
1586
- if (this.pivoted()) {
1587
- this.columns.set(this._pivotColumns(data));
1588
- this.truncated.set(!!data.truncated);
1589
- }
1590
- return {
1591
- data: data.rows ?? [],
1592
- paging: {
1593
- limit: data.paging?.pageSize ?? state.pageSize,
1594
- records: data.paging?.total ?? (data.rows?.length ?? 0),
1595
- page: data.paging?.page ?? state.page,
1596
- },
1597
- };
1598
- }));
1599
- }
1600
- // A cross-tab's columns, built from the wire. The first is the row key and
1601
- // keeps its authored label; the rest are pivot VALUES used as headings. When
1602
- // the pivot axis is a time dimension the server sends the grain it bucketed
1603
- // at, so a period-start like "2026-01-01" is shown as "Jan 2026" — the same
1604
- // formatter the chart's x-axis uses, so a grid and a chart of the same query
1605
- // label their periods identically.
1606
- _pivotColumns(data) {
1607
- const config = (this.component.config ?? {});
1608
- const granularity = data.granularity;
1609
- const multiYear = this._spansMultipleYears(data.columns ?? []);
1610
- return (data.columns ?? []).map((column, index) => {
1611
- if (index === 0) {
1612
- return { column, label: config.rowBy?.label || column };
1613
- }
1614
- return {
1615
- column,
1616
- label: granularity ? formatPeriodLabel(column, granularity, multiYear) : column,
1617
- };
1618
- });
1619
- }
1620
- // Whether the period columns cross a year boundary, which decides if the
1621
- // formatter appends the year to each heading.
1622
- _spansMultipleYears(columns) {
1623
- const years = new Set(columns
1624
- .map((column) => /^(\d{4})-\d{2}-\d{2}/.exec(column))
1625
- .filter(Boolean)
1626
- .map((match) => match[1]));
1627
- return years.size > 1;
1628
- }
1629
- // The list's own FsFilter values, read back out of the fetch query by the
1630
- // `f<filterId>` naming convention.
1631
- _barValues(query) {
1632
- const values = [];
1633
- for (const filter of this._ownFilters()) {
1634
- const group = this.groups?.get(filter.filterGroupId);
1635
- const name = `f${filter.id}`;
1636
- if (group?.type === 'dateRange') {
1637
- const from = dateBoundString(query[`${name}From`]);
1638
- const to = dateBoundString(query[`${name}To`]);
1639
- if (from || to) {
1640
- values.push({ filterId: filter.id, start: from, end: to });
1641
- }
1642
- }
1643
- else if (group?.type === 'select') {
1644
- // fs-filter joins a multi-select into one "a,b" string; split it back
1645
- // so each value binds separately (else `col IN ('a,b')` matches nothing).
1646
- const selected = query[name];
1647
- const selectedValues = Array.isArray(selected)
1648
- ? selected
1649
- : (selected ? String(selected).split(',') : []);
1650
- if (selectedValues.length) {
1651
- values.push({ filterId: filter.id, values: selectedValues });
1652
- }
1653
- }
1654
- else {
1655
- const keyword = String(query[name] ?? '').trim();
1656
- if (keyword) {
1657
- values.push({ filterId: filter.id, value: keyword });
1658
- }
1659
- }
1660
- }
1661
- return values;
1662
- }
1663
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1664
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentListComponent, isStandalone: true, selector: "app-report-component-list", inputs: { reportId: "reportId", component: "component", groups: "groups" }, viewQueries: [{ propertyName: "listComponent", first: true, predicate: FsListComponent, descendants: true }], ngImport: i0, template: "@if (truncated()) {\n <div class=\"pivot-truncated\">\n This grid was built from a partial result, so some columns or values may be missing. Narrow it with a filter.\n </div>\n}\n\n<fs-list [config]=\"listConfig\">\n @for (column of columns(); track column.column) {\n <fs-list-column\n [name]=\"column.column\"\n [title]=\"column.label || column.column\"\n [sortable]=\"!pivoted() || $first\">\n <ng-template\n fs-list-cell\n let-row=\"row\">\n @if (column.format === 'date') {\n {{ row[column.column] | date: 'mediumDate' }}\n } @else {\n {{ row[column.column] }}\n }\n </ng-template>\n </fs-list-column>\n }\n</fs-list>\n", styles: [":host{display:block;width:100%;height:100%;min-height:0}:host ::ng-deep fs-list{display:flex;flex-direction:column;height:100%;min-height:0}:host ::ng-deep fs-list .fs-list-container{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;height:100%}:host ::ng-deep fs-list .fs-list-header-container{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-container .fs-list-table-container{flex:1 1 auto;min-height:0;overflow-y:auto!important}:host ::ng-deep fs-list .fs-list-table thead th{position:sticky;top:0;z-index:1;background-color:#fff}:host ::ng-deep fs-list fs-list-pagination{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-load-more{position:sticky;bottom:0;background-color:#fff}:host ::ng-deep fs-list .fs-list-load-more button{margin-top:0}.pivot-truncated{flex:0 0 auto;padding:8px 12px;margin-bottom:8px;border-radius:4px;background-color:#fff4e5;color:#663c00}\n"], dependencies: [{ kind: "pipe", type: DatePipe, name: "date" }, { kind: "ngmodule", type: FsListModule }, { kind: "component", type: i1.FsListComponent, selector: "fs-list", inputs: ["config", "loaderLines", "cellRowType"], outputs: ["filtersReady"] }, { kind: "directive", type: i1.FsListColumnDirective, selector: "fs-list-column", inputs: ["show", "title", "name", "customizable", "sortable", "sortableDefault", "sortableDirection", "direction", "align", "width", "class"] }, { kind: "directive", type: i1.FsListCellDirective, selector: "[fs-list-cell],[fsListCell]", inputs: ["colspan", "align", "class", "fsListCell", "configTyping"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1665
- }
1666
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentListComponent, decorators: [{
1667
- type: Component,
1668
- args: [{ selector: 'app-report-component-list', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
1669
- DatePipe,
1670
- FsListModule,
1671
- ], template: "@if (truncated()) {\n <div class=\"pivot-truncated\">\n This grid was built from a partial result, so some columns or values may be missing. Narrow it with a filter.\n </div>\n}\n\n<fs-list [config]=\"listConfig\">\n @for (column of columns(); track column.column) {\n <fs-list-column\n [name]=\"column.column\"\n [title]=\"column.label || column.column\"\n [sortable]=\"!pivoted() || $first\">\n <ng-template\n fs-list-cell\n let-row=\"row\">\n @if (column.format === 'date') {\n {{ row[column.column] | date: 'mediumDate' }}\n } @else {\n {{ row[column.column] }}\n }\n </ng-template>\n </fs-list-column>\n }\n</fs-list>\n", styles: [":host{display:block;width:100%;height:100%;min-height:0}:host ::ng-deep fs-list{display:flex;flex-direction:column;height:100%;min-height:0}:host ::ng-deep fs-list .fs-list-container{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;height:100%}:host ::ng-deep fs-list .fs-list-header-container{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-container .fs-list-table-container{flex:1 1 auto;min-height:0;overflow-y:auto!important}:host ::ng-deep fs-list .fs-list-table thead th{position:sticky;top:0;z-index:1;background-color:#fff}:host ::ng-deep fs-list fs-list-pagination{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-load-more{position:sticky;bottom:0;background-color:#fff}:host ::ng-deep fs-list .fs-list-load-more button{margin-top:0}.pivot-truncated{flex:0 0 auto;padding:8px 12px;margin-bottom:8px;border-radius:4px;background-color:#fff4e5;color:#663c00}\n"] }]
1672
- }], propDecorators: { reportId: [{
1673
- type: Input
1674
- }], component: [{
1675
- type: Input
1676
- }], groups: [{
1677
- type: Input
1678
- }], listComponent: [{
1679
- type: ViewChild,
1680
- args: [FsListComponent]
1681
- }] } });
1682
-
1683
- // Text blocks — shared by the canvas and both exports, so a block reads the
1684
- // same on screen, in the deck and in the PDF.
1685
- //
1686
- // A block's html arrives already rebuilt server-side from a small allowlist
1687
- // (ReportTextHtml): p, h1-h3, strong, em, u, s, ul/ol/li, br, a, and span with
1688
- // color / background-color / font-size / font-weight / font-family, plus
1689
- // text-align on a block. Everything here only has to understand that set.
1690
- // Body text size, points, and the heading sizes — the same numbers the canvas
1691
- // stylesheet uses, so an unstyled paragraph is one size everywhere.
1692
- const DEFAULT_TEXT_SIZE = 12;
1693
- const HEADING_TEXT_SIZES = { 1: 24, 2: 18, 3: 14 };
1694
- const DEFAULT_TEXT_COLOR = '#1f2933';
1695
- // The tokens a block may carry, with what fills each. {{page.number}} is the
1696
- // page's position among the report's pages (1-based), which is what makes a
1697
- // shared footer number every page correctly.
1698
- const TOKENS$1 = {
1699
- 'page.number': (context) => String(context.pageNumber),
1700
- 'page.name': (context) => context.pageName,
1701
- 'report.name': (context) => context.reportName,
1702
- };
1703
- // Replace every {{token}} in the html. Values are html-escaped: a page named
1704
- // "R&D <draft>" must read as that, not as markup.
1705
- function fillTextTokens(html, context) {
1706
- return html.replace(/\{\{\s*([a-z.]+)\s*\}\}/gi, (match, token) => {
1707
- const fill = TOKENS$1[token.toLowerCase()];
1708
- return fill ? escapeHtml(fill(context)) : match;
1709
- });
1710
- }
1711
- // Walk a block's html into paragraphs of styled runs — the shape both exports
1712
- // draw from. Tokens are filled first so a page number is just text by then.
1713
- function textParagraphs(html, context) {
1714
- const document = new DOMParser().parseFromString(`<body>${fillTextTokens(html, context)}</body>`, 'text/html');
1715
- const paragraphs = [];
1716
- walkBlocks(document.body, paragraphs, null);
1717
- return paragraphs.filter((paragraph, index) =>
1718
- // Keep an empty paragraph between two others (a deliberate blank line), but
1719
- // not one left dangling at either end.
1720
- paragraph.runs.length || (index > 0 && index < paragraphs.length - 1));
1721
- }
1722
- // The html as plain text — what a block says, for anything that cannot style it.
1723
- function textPlain(html, context) {
1724
- return textParagraphs(html, context)
1725
- .map((paragraph) => paragraph.runs.map((run) => run.text).join(''))
1726
- .join('\n');
1727
- }
1728
- function walkBlocks(parent, paragraphs, list) {
1729
- // Loose inline content between blocks (text straight under <body> or <li>)
1730
- // is gathered into a paragraph of its own.
1731
- let loose = null;
1732
- const flush = () => {
1733
- if (loose) {
1734
- paragraphs.push(loose);
1735
- loose = null;
1736
- }
1737
- };
1738
- parent.childNodes.forEach((node) => {
1739
- const name = node.nodeName.toLowerCase();
1740
- if (name === 'ul' || name === 'ol') {
1741
- flush();
1742
- walkBlocks(node, paragraphs, name === 'ul' ? 'bullet' : 'number');
1743
- return;
1744
- }
1745
- if (name === 'li') {
1746
- flush();
1747
- const item = { runs: [], bullet: list ?? 'bullet' };
1748
- const nested = [];
1749
- // A list item's first paragraph IS the item; anything after it is its own
1750
- // line under the same bullet.
1751
- walkBlocks(node, nested, list);
1752
- const [first, ...rest] = nested.length ? nested : [item];
1753
- paragraphs.push({ ...first, bullet: list ?? 'bullet' }, ...rest);
1754
- return;
1755
- }
1756
- if (name === 'p' || /^h[1-3]$/.test(name)) {
1757
- flush();
1758
- const element = node;
1759
- const heading = name.startsWith('h') ? Number(name[1]) : undefined;
1760
- const paragraph = {
1761
- runs: [],
1762
- align: blockAlign(element),
1763
- bullet: undefined,
1764
- heading,
1765
- };
1766
- const base = heading
1767
- ? { bold: true, fontSize: HEADING_TEXT_SIZES[heading] }
1768
- : {};
1769
- walkInline(node, base, paragraph, paragraphs);
1770
- paragraphs.push(paragraph);
1771
- return;
1772
- }
1773
- loose = loose ?? { runs: [] };
1774
- walkInline(node, {}, loose, paragraphs, true);
1775
- });
1776
- flush();
1777
- }
1778
- // Accumulate inline styles down the tree. A <br> ends the current line: the
1779
- // runs after it start a new paragraph with the same alignment and bullet.
1780
- function walkInline(node, style, paragraph, paragraphs, isSelf = false) {
1781
- const visit = (child) => {
1782
- if (child.nodeType === Node.TEXT_NODE) {
1783
- const text = child.textContent ?? '';
1784
- if (text) {
1785
- paragraph.runs.push({ ...style, text });
1786
- }
1787
- return;
1788
- }
1789
- if (child.nodeType !== Node.ELEMENT_NODE) {
1790
- return;
1791
- }
1792
- const element = child;
1793
- const name = element.nodeName.toLowerCase();
1794
- if (name === 'br') {
1795
- paragraphs.push({ ...paragraph, runs: [...paragraph.runs] });
1796
- paragraph.runs.length = 0;
1797
- return;
1612
+ if (!seen.has(value)) {
1613
+ seen.add(value);
1614
+ values.push(value);
1798
1615
  }
1799
- walkInline(element, { ...style, ...inlineStyle(element) }, paragraph, paragraphs);
1800
- };
1801
- if (isSelf) {
1802
- visit(node);
1803
1616
  }
1804
- else {
1805
- node.childNodes.forEach(visit);
1617
+ return values;
1618
+ }
1619
+ function numeric(value) {
1620
+ if (value === null || value === undefined || value === '') {
1621
+ return null;
1806
1622
  }
1623
+ const parsed = Number(value);
1624
+ return Number.isFinite(parsed) ? parsed : null;
1807
1625
  }
1808
- function inlineStyle(element) {
1809
- const name = element.nodeName.toLowerCase();
1810
- const style = {};
1811
- if (name === 'strong') {
1812
- style.bold = true;
1626
+ // The effective time bucket the rows were grouped into (from the data endpoint),
1627
+ // or null when the x-axis isn't a time axis — gates the compact period labels.
1628
+ function bucketGranularity(data) {
1629
+ return data.granularity && GRANULARITY_OPTIONS.some((option) => option.value === data.granularity)
1630
+ ? data.granularity
1631
+ : null;
1632
+ }
1633
+ // Whether the period-start categories cross a year boundary. When they don't,
1634
+ // the year is pure noise on every label, so it's dropped entirely.
1635
+ function spansMultipleYears(categories) {
1636
+ const years = new Set();
1637
+ for (const category of categories) {
1638
+ const date = parseISO(category);
1639
+ if (isValid(date)) {
1640
+ years.add(date.getFullYear());
1641
+ }
1813
1642
  }
1814
- if (name === 'em') {
1815
- style.italic = true;
1643
+ return years.size > 1;
1644
+ }
1645
+ // A bucket's period-start split into its primary label and its year. Day/week
1646
+ // read as "May 1", month as "May", quarter as "Q2", year as "2025". Returns null
1647
+ // for a value that isn't a parseable date (left as-is by callers).
1648
+ function periodLabelParts(value, granularity) {
1649
+ const date = parseISO(value);
1650
+ if (!isValid(date)) {
1651
+ return null;
1816
1652
  }
1817
- if (name === 'u') {
1818
- style.underline = true;
1653
+ const year = String(date.getFullYear());
1654
+ switch (granularity) {
1655
+ case 'year':
1656
+ return { primary: year, year };
1657
+ case 'quarter':
1658
+ return { primary: `Q${Math.floor(date.getMonth() / 3) + 1}`, year };
1659
+ case 'month':
1660
+ return { primary: format(date, 'MMMM'), year };
1661
+ default: // day, week — the bucket start as a compact "MMM d"
1662
+ return { primary: format(date, 'MMM d'), year };
1819
1663
  }
1820
- if (name === 's') {
1821
- style.strike = true;
1664
+ }
1665
+ // A compact single-line period label (year appended only when the data spans
1666
+ // years) — for media that can't do a styled second line, e.g. PowerPoint.
1667
+ function formatPeriodLabel(value, granularity, multiYear) {
1668
+ const parts = periodLabelParts(value, granularity);
1669
+ if (!parts) {
1670
+ return value;
1822
1671
  }
1823
- if (name === 'a') {
1824
- style.link = element.getAttribute('href') ?? undefined;
1825
- style.underline = true;
1672
+ return multiYear && granularity !== 'year' ? `${parts.primary} ${parts.year}` : parts.primary;
1673
+ }
1674
+ // ECharts category-axis label config for a time bucket: the primary label on
1675
+ // line one, and — only when the data spans multiple years — the year on a small
1676
+ // gray second line, centered under the tick (year granularity needs no second
1677
+ // line, it already is the year). Empty for a non-time axis (labels as-is).
1678
+ function periodAxisLabel(granularity, categories) {
1679
+ if (!granularity) {
1680
+ return {};
1826
1681
  }
1827
- const css = element.style;
1828
- if (css.color) {
1829
- style.color = hexColor(css.color);
1682
+ const multiYear = spansMultipleYears(categories);
1683
+ return {
1684
+ axisLabel: {
1685
+ formatter: (value) => {
1686
+ const parts = periodLabelParts(value, granularity);
1687
+ if (!parts) {
1688
+ return value;
1689
+ }
1690
+ return multiYear && granularity !== 'year'
1691
+ ? `${parts.primary}\n{yr|${parts.year}}`
1692
+ : parts.primary;
1693
+ },
1694
+ rich: { yr: { fontSize: 10, color: '#9aa0a6', lineHeight: 14 } },
1695
+ },
1696
+ };
1697
+ }
1698
+
1699
+ // ECharts host for a chart component. Pure rendering: the parent owns the
1700
+ // data fetch; this maps (component config, rows) → option and exposes the
1701
+ // chart instance for resize + the future image exports (getDataURL needs the
1702
+ // canvas renderer registered in theme/echarts.ts).
1703
+ class ComponentChartComponent {
1704
+ component;
1705
+ data = null;
1706
+ options = null;
1707
+ _chart = null;
1708
+ ngOnChanges() {
1709
+ this.options = this.component && this.data
1710
+ ? buildChartOption(this.component, this.data)
1711
+ : null;
1830
1712
  }
1831
- if (css.backgroundColor) {
1832
- style.highlight = hexColor(css.backgroundColor);
1713
+ onChartInit(chart) {
1714
+ this._chart = chart;
1833
1715
  }
1834
- if (css.fontSize) {
1835
- style.fontSize = points(css.fontSize);
1716
+ // Called by the canvas when the box is resized in edit mode.
1717
+ resize() {
1718
+ this._chart?.resize();
1836
1719
  }
1837
- if (css.fontWeight) {
1838
- style.bold = css.fontWeight === 'bold' || Number(css.fontWeight) >= 600;
1720
+ // High-res image for the (later-phase) PPT/PDF exports.
1721
+ getDataURL(pixelRatio = 3) {
1722
+ return this._chart?.getDataURL({
1723
+ type: 'png',
1724
+ pixelRatio,
1725
+ backgroundColor: '#ffffff',
1726
+ }) ?? null;
1839
1727
  }
1840
- if (css.fontStyle) {
1841
- style.italic = css.fontStyle === 'italic';
1728
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentChartComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1729
+ 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 });
1730
+ }
1731
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentChartComponent, decorators: [{
1732
+ type: Component,
1733
+ args: [{ selector: 'app-report-component-chart', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
1734
+ NgxEchartsDirective,
1735
+ ], 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"] }]
1736
+ }], propDecorators: { component: [{
1737
+ type: Input
1738
+ }], data: [{
1739
+ type: Input
1740
+ }] } });
1741
+
1742
+ // A single big number: the component's measure aggregated over all (filtered)
1743
+ // rows server-side; this just formats the one cell.
1744
+ class ComponentKpiComponent {
1745
+ component;
1746
+ data = null;
1747
+ value = '—';
1748
+ label = '';
1749
+ ngOnChanges() {
1750
+ const config = (this.component?.config ?? {});
1751
+ this.label = config.measure?.label ?? '';
1752
+ const row = this.data?.rows?.[0];
1753
+ if (!row) {
1754
+ this.value = '—';
1755
+ return;
1756
+ }
1757
+ const column = config.measure?.column ?? Object.keys(row)[0];
1758
+ this.value = formatMeasureValue(row[column], config.measure?.format, config.measure?.precision);
1842
1759
  }
1843
- if (css.textDecoration) {
1844
- style.underline = css.textDecoration.includes('underline');
1845
- style.strike = css.textDecoration.includes('line-through');
1760
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentKpiComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1761
+ 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 });
1762
+ }
1763
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentKpiComponent, decorators: [{
1764
+ type: Component,
1765
+ 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"] }]
1766
+ }], propDecorators: { component: [{
1767
+ type: Input
1768
+ }], data: [{
1769
+ type: Input
1770
+ }] } });
1771
+
1772
+ // A list component: FsList + FsFilter rendered from the component's config and
1773
+ // its report_filters rows. The component's own filter bar carries its filters
1774
+ // whose group renders at the component level; report-level groups apply
1775
+ // through the shared filter-state service and merge into every request.
1776
+ // Paging and sorting are pushed into the SQL wrap server-side.
1777
+ class ComponentListComponent {
1778
+ reportId;
1779
+ component;
1780
+ groups;
1781
+ listComponent;
1782
+ listConfig;
1783
+ // A plain list's columns are authored and fixed at init. A cross-tab's are
1784
+ // the pivot dimension's VALUES, so they arrive with the rows and change
1785
+ // whenever the filters do — which is why this is a signal rather than the
1786
+ // one-shot read it used to be.
1787
+ columns = signal([]);
1788
+ // A cross-tab sorts only by its row-key column: every other header is a
1789
+ // pivot value, and the server orders the long rows before pivoting, so a
1790
+ // click on "Mar 2026" has nothing to order by.
1791
+ pivoted = signal(false);
1792
+ // A cut result is worse for a grid than for a list: the cut lands on the LONG
1793
+ // rows before the reshape, so a pivot value living only in the tail vanishes
1794
+ // as a whole COLUMN and straddled cells come back blank, indistinguishable
1795
+ // from a real zero. The parent's truncation banner never fires for a list
1796
+ // (it only renders what _load() fetched, which lists skip), so the warning
1797
+ // belongs here.
1798
+ truncated = signal(false);
1799
+ _reportData = inject(ReportData);
1800
+ _filterState = inject(ReportFilterStateService);
1801
+ _destroyRef = inject(DestroyRef);
1802
+ ngOnInit() {
1803
+ const config = (this.component.config ?? {});
1804
+ this.pivoted.set(!!config.pivotBy?.column);
1805
+ this.columns.set(config.columns ?? []);
1806
+ this._initListConfig(config);
1807
+ // Report-level filter changes that scope this component re-run the list.
1808
+ this._filterState.changesFor(this.component)
1809
+ .pipe(takeUntilDestroyed(this._destroyRef))
1810
+ .subscribe(() => this.listComponent?.reload());
1811
+ }
1812
+ _initListConfig(config) {
1813
+ this.listConfig = {
1814
+ // Inside a report component the list is a clean data pane — no status
1815
+ // bar, no reload button (filter changes already refetch).
1816
+ status: false,
1817
+ reload: false,
1818
+ // Never touch the URL or persist filter/paging state — report filtering
1819
+ // is session-only.
1820
+ queryParam: false,
1821
+ persist: false,
1822
+ sort: config.sort
1823
+ ? { value: config.sort.column, direction: config.sort.direction ?? 'asc' }
1824
+ : undefined,
1825
+ // Load-more: the list keeps a fixed, scrollable height and appends the
1826
+ // next page in place instead of swapping pages. The button is pinned to
1827
+ // the bottom of the scroll pane (see component SCSS).
1828
+ //
1829
+ // Uses the Offset strategy (offset + limit) rather than Many: in
1830
+ // @firestitch/list the pagination template resolves `hasManyStrategy`
1831
+ // BEFORE `loadMoreEnabled`, and Many renders a forward-only page pager —
1832
+ // so Many would suppress the load-more button entirely. Offset + loadMore
1833
+ // is the combination that actually renders and appends. `_fetch` already
1834
+ // derives `page` from `offset`, so the server-side wrap is unaffected.
1835
+ paging: {
1836
+ limit: config.pageSize ?? 25,
1837
+ strategy: PaginationStrategy.Offset,
1838
+ },
1839
+ loadMore: {
1840
+ enabled: true,
1841
+ label: 'Load more',
1842
+ buttonType: 'stroked',
1843
+ },
1844
+ filters: this._ownFilters().map((filter) => this._filterItem(filter)),
1845
+ fetch: (query) => this._fetch(query),
1846
+ };
1847
+ }
1848
+ // The filters this list renders in its own bar: its report_filters whose
1849
+ // group is component-level. Report-level groups render in the report bar.
1850
+ _ownFilters() {
1851
+ return (this.component.filters ?? [])
1852
+ .filter((filter) => {
1853
+ const group = this.groups?.get(filter.filterGroupId);
1854
+ return filter.enabled && (!group || group.level === 'component' || group.level === 'both');
1855
+ });
1846
1856
  }
1847
- if (css.fontFamily) {
1848
- style.fontFace = css.fontFamily.split(',')[0].replace(/["']/g, '').trim();
1857
+ _filterItem(filter) {
1858
+ const group = this.groups?.get(filter.filterGroupId);
1859
+ const name = `f${filter.id}`;
1860
+ const label = filter.label || group?.label || filter.filterColumn;
1861
+ switch (group?.type) {
1862
+ case 'dateRange':
1863
+ return {
1864
+ name,
1865
+ type: ItemType.DateRange,
1866
+ label: [`${label} From`, `${label} To`],
1867
+ };
1868
+ case 'select':
1869
+ return {
1870
+ name,
1871
+ type: ItemType.AutoCompleteChips,
1872
+ label,
1873
+ // The server sends { value, label }: the filter matches on the value
1874
+ // (an entity's stable id) while the viewer reads the label.
1875
+ values: () => this._reportData.filterOptions(this.reportId, filter.id)
1876
+ .pipe(map$1((options) => (options ?? [])
1877
+ .map((option) => ({ name: option.label, value: option.value })))),
1878
+ };
1879
+ case 'keyword':
1880
+ default:
1881
+ return {
1882
+ name,
1883
+ type: ItemType.Keyword,
1884
+ label,
1885
+ };
1886
+ }
1849
1887
  }
1850
- return style;
1851
- }
1852
- function blockAlign(element) {
1853
- const align = element.style.textAlign;
1854
- return align === 'center' || align === 'right' || align === 'justify' ? align : undefined;
1855
- }
1856
- // A CSS font-size in points. The canvas renders at 96px per inch, so 1px is
1857
- // exactly 0.75pt; em/rem/% are relative to the body size.
1858
- function points(value) {
1859
- const match = /^([\d.]+)(pt|px|em|rem|%)$/.exec(value.trim());
1860
- if (!match) {
1861
- return undefined;
1888
+ _fetch(query) {
1889
+ // Many/load-more sends `offset` + `limit` (not `page`); derive the page so
1890
+ // the server-side wrap keeps working unchanged. Page strategy still sends
1891
+ // `page` directly.
1892
+ const pageSize = query.limit ?? 25;
1893
+ const page = query.page
1894
+ ?? (query.offset != null ? Math.floor(query.offset / pageSize) + 1 : 1);
1895
+ const state = {
1896
+ page,
1897
+ pageSize,
1898
+ };
1899
+ const order = String(query.order ?? '');
1900
+ if (order) {
1901
+ const [column, direction] = order.split(',');
1902
+ state.sort = { column, direction: direction === 'desc' ? 'desc' : 'asc' };
1903
+ }
1904
+ // Report-level values from the shared session state, merged with this
1905
+ // list's own filter-bar values (own values win per filter).
1906
+ const resolved = new Map();
1907
+ for (const value of this._filterState.resolveForComponent(this.component)) {
1908
+ resolved.set(value.filterId, value);
1909
+ }
1910
+ for (const value of this._barValues(query)) {
1911
+ resolved.set(value.filterId, value);
1912
+ }
1913
+ return this._reportData
1914
+ .componentData(this.reportId, this.component.id, [...resolved.values()], state)
1915
+ .pipe(map$1((data) => {
1916
+ // A cross-tab's headers come back with the rows: the row-key column
1917
+ // first, then one per pivot value. Re-read them on every fetch, since
1918
+ // a filter change can add or remove a column.
1919
+ if (this.pivoted()) {
1920
+ this.columns.set(this._pivotColumns(data));
1921
+ this.truncated.set(!!data.truncated);
1922
+ }
1923
+ return {
1924
+ data: data.rows ?? [],
1925
+ paging: {
1926
+ limit: data.paging?.pageSize ?? state.pageSize,
1927
+ records: data.paging?.total ?? (data.rows?.length ?? 0),
1928
+ page: data.paging?.page ?? state.page,
1929
+ },
1930
+ };
1931
+ }));
1862
1932
  }
1863
- const size = Number(match[1]);
1864
- switch (match[2]) {
1865
- case 'pt':
1866
- return size;
1867
- case 'px':
1868
- return size * 0.75;
1869
- case '%':
1870
- return (size / 100) * DEFAULT_TEXT_SIZE;
1871
- default:
1872
- return size * DEFAULT_TEXT_SIZE;
1933
+ // A cross-tab's columns, built from the wire. The first is the row key and
1934
+ // keeps its authored label; the rest are pivot VALUES used as headings. When
1935
+ // the pivot axis is a time dimension the server sends the grain it bucketed
1936
+ // at, so a period-start like "2026-01-01" is shown as "Jan 2026" — the same
1937
+ // formatter the chart's x-axis uses, so a grid and a chart of the same query
1938
+ // label their periods identically.
1939
+ _pivotColumns(data) {
1940
+ const config = (this.component.config ?? {});
1941
+ const granularity = data.granularity;
1942
+ const multiYear = this._spansMultipleYears(data.columns ?? []);
1943
+ return (data.columns ?? []).map((column, index) => {
1944
+ if (index === 0) {
1945
+ return { column, label: config.rowBy?.label || column };
1946
+ }
1947
+ return {
1948
+ column,
1949
+ label: granularity ? formatPeriodLabel(column, granularity, multiYear) : column,
1950
+ };
1951
+ });
1873
1952
  }
1874
- }
1875
- // Any CSS color the allowlist lets through (hex, rgb(), a name) as #rrggbb —
1876
- // the one form both exports accept. The browser resolves it, so a named color
1877
- // comes out exactly as the canvas painted it.
1878
- let colorContext = null;
1879
- function hexColor(value) {
1880
- colorContext = colorContext ?? document.createElement('canvas').getContext('2d');
1881
- if (!colorContext) {
1882
- return undefined;
1953
+ // Whether the period columns cross a year boundary, which decides if the
1954
+ // formatter appends the year to each heading.
1955
+ _spansMultipleYears(columns) {
1956
+ const years = new Set(columns
1957
+ .map((column) => /^(\d{4})-\d{2}-\d{2}/.exec(column))
1958
+ .filter(Boolean)
1959
+ .map((match) => match[1]));
1960
+ return years.size > 1;
1883
1961
  }
1884
- colorContext.fillStyle = '#000000';
1885
- colorContext.fillStyle = value;
1886
- const resolved = String(colorContext.fillStyle);
1887
- if (resolved.startsWith('#')) {
1888
- return resolved;
1962
+ // The list's own FsFilter values, read back out of the fetch query by the
1963
+ // `f<filterId>` naming convention.
1964
+ _barValues(query) {
1965
+ const values = [];
1966
+ for (const filter of this._ownFilters()) {
1967
+ const group = this.groups?.get(filter.filterGroupId);
1968
+ const name = `f${filter.id}`;
1969
+ if (group?.type === 'dateRange') {
1970
+ const from = dateBoundString(query[`${name}From`]);
1971
+ const to = dateBoundString(query[`${name}To`]);
1972
+ if (from || to) {
1973
+ values.push({ filterId: filter.id, start: from, end: to });
1974
+ }
1975
+ }
1976
+ else if (group?.type === 'select') {
1977
+ // fs-filter joins a multi-select into one "a,b" string; split it back
1978
+ // so each value binds separately (else `col IN ('a,b')` matches nothing).
1979
+ const selected = query[name];
1980
+ const selectedValues = Array.isArray(selected)
1981
+ ? selected
1982
+ : (selected ? String(selected).split(',') : []);
1983
+ if (selectedValues.length) {
1984
+ values.push({ filterId: filter.id, values: selectedValues });
1985
+ }
1986
+ }
1987
+ else {
1988
+ const keyword = String(query[name] ?? '').trim();
1989
+ if (keyword) {
1990
+ values.push({ filterId: filter.id, value: keyword });
1991
+ }
1992
+ }
1993
+ }
1994
+ return values;
1889
1995
  }
1890
- // rgba(...) for a translucent color — drop the alpha, keep the hue.
1891
- const channels = resolved.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [];
1892
- return channels.length === 3
1893
- ? `#${channels.map((channel) => Math.round(channel).toString(16).padStart(2, '0')).join('')}`
1894
- : undefined;
1895
- }
1896
- function escapeHtml(value) {
1897
- return value
1898
- .replace(/&/g, '&amp;')
1899
- .replace(/</g, '&lt;')
1900
- .replace(/>/g, '&gt;')
1901
- .replace(/"/g, '&quot;');
1996
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1997
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ComponentListComponent, isStandalone: true, selector: "app-report-component-list", inputs: { reportId: "reportId", component: "component", groups: "groups" }, viewQueries: [{ propertyName: "listComponent", first: true, predicate: FsListComponent, descendants: true }], ngImport: i0, template: "@if (truncated()) {\n <div class=\"pivot-truncated\">\n This grid was built from a partial result, so some columns or values may be missing. Narrow it with a filter.\n </div>\n}\n\n<fs-list [config]=\"listConfig\">\n @for (column of columns(); track column.column) {\n <fs-list-column\n [name]=\"column.column\"\n [title]=\"column.label || column.column\"\n [sortable]=\"!pivoted() || $first\">\n <ng-template\n fs-list-cell\n let-row=\"row\">\n @if (column.format === 'date') {\n {{ row[column.column] | date: 'mediumDate' }}\n } @else {\n {{ row[column.column] }}\n }\n </ng-template>\n </fs-list-column>\n }\n</fs-list>\n", styles: [":host{display:block;width:100%;height:100%;min-height:0}:host ::ng-deep fs-list{display:flex;flex-direction:column;height:100%;min-height:0}:host ::ng-deep fs-list .fs-list-container{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;height:100%}:host ::ng-deep fs-list .fs-list-header-container{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-container .fs-list-table-container{flex:1 1 auto;min-height:0;overflow-y:auto!important}:host ::ng-deep fs-list .fs-list-table thead th{position:sticky;top:0;z-index:1;background-color:#fff}:host ::ng-deep fs-list fs-list-pagination{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-load-more{position:sticky;bottom:0;background-color:#fff}:host ::ng-deep fs-list .fs-list-load-more button{margin-top:0}.pivot-truncated{flex:0 0 auto;padding:8px 12px;margin-bottom:8px;border-radius:4px;background-color:#fff4e5;color:#663c00}\n"], dependencies: [{ kind: "pipe", type: DatePipe, name: "date" }, { kind: "ngmodule", type: FsListModule }, { kind: "component", type: i1.FsListComponent, selector: "fs-list", inputs: ["config", "loaderLines", "cellRowType"], outputs: ["filtersReady"] }, { kind: "directive", type: i1.FsListColumnDirective, selector: "fs-list-column", inputs: ["show", "title", "name", "customizable", "sortable", "sortableDefault", "sortableDirection", "direction", "align", "width", "class"] }, { kind: "directive", type: i1.FsListCellDirective, selector: "[fs-list-cell],[fsListCell]", inputs: ["colspan", "align", "class", "fsListCell", "configTyping"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1902
1998
  }
1999
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ComponentListComponent, decorators: [{
2000
+ type: Component,
2001
+ args: [{ selector: 'app-report-component-list', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [
2002
+ DatePipe,
2003
+ FsListModule,
2004
+ ], template: "@if (truncated()) {\n <div class=\"pivot-truncated\">\n This grid was built from a partial result, so some columns or values may be missing. Narrow it with a filter.\n </div>\n}\n\n<fs-list [config]=\"listConfig\">\n @for (column of columns(); track column.column) {\n <fs-list-column\n [name]=\"column.column\"\n [title]=\"column.label || column.column\"\n [sortable]=\"!pivoted() || $first\">\n <ng-template\n fs-list-cell\n let-row=\"row\">\n @if (column.format === 'date') {\n {{ row[column.column] | date: 'mediumDate' }}\n } @else {\n {{ row[column.column] }}\n }\n </ng-template>\n </fs-list-column>\n }\n</fs-list>\n", styles: [":host{display:block;width:100%;height:100%;min-height:0}:host ::ng-deep fs-list{display:flex;flex-direction:column;height:100%;min-height:0}:host ::ng-deep fs-list .fs-list-container{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;height:100%}:host ::ng-deep fs-list .fs-list-header-container{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-container .fs-list-table-container{flex:1 1 auto;min-height:0;overflow-y:auto!important}:host ::ng-deep fs-list .fs-list-table thead th{position:sticky;top:0;z-index:1;background-color:#fff}:host ::ng-deep fs-list fs-list-pagination{flex:0 0 auto}:host ::ng-deep fs-list .fs-list-load-more{position:sticky;bottom:0;background-color:#fff}:host ::ng-deep fs-list .fs-list-load-more button{margin-top:0}.pivot-truncated{flex:0 0 auto;padding:8px 12px;margin-bottom:8px;border-radius:4px;background-color:#fff4e5;color:#663c00}\n"] }]
2005
+ }], propDecorators: { reportId: [{
2006
+ type: Input
2007
+ }], component: [{
2008
+ type: Input
2009
+ }], groups: [{
2010
+ type: Input
2011
+ }], listComponent: [{
2012
+ type: ViewChild,
2013
+ args: [FsListComponent]
2014
+ }] } });
1903
2015
 
1904
2016
  // Point sizes — the report's type unit (1:1 with PowerPoint and PDF).
1905
2017
  const FONT_SIZES = ['8', '9', '10', '11', '12', '14', '16', '18', '20', '24', '28', '32', '36', '40', '48', '60', '72'];
@@ -3910,7 +4022,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
3910
4022
  // complete synchronously after setOption.
3911
4023
  async function renderChartImage(component, data, width, height, pixelRatio = 3) {
3912
4024
  // The same tree-shaken echarts build + 'report' theme the live canvas uses.
3913
- const echarts = (await import('./firestitch-report-echarts-CFkoNvo_.mjs')).default;
4025
+ const echarts = (await import('./firestitch-report-echarts-CtKYuRcT.mjs')).default;
3914
4026
  const host = document.createElement('div');
3915
4027
  host.style.width = `${width}px`;
3916
4028
  host.style.height = `${height}px`;
@@ -4236,16 +4348,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
4236
4348
  type: Injectable
4237
4349
  }] });
4238
4350
 
4239
- // The report chart palette — one definition shared by the ECharts theme (on
4240
- // screen), the PowerPoint export's native chart colors, and the PDF export,
4241
- // so a series keeps its color across every medium.
4242
- const REPORT_CHART_COLORS = [
4243
- '4e79a7', 'f28e2b', 'e15759', '76b7b2', '59a14f',
4244
- 'edc948', 'b07aa1', 'ff9da7', '9c755f', 'bab0ac',
4245
- ];
4246
- // CSS-hex form for ECharts.
4247
- const REPORT_CHART_COLORS_CSS = REPORT_CHART_COLORS.map((color) => `#${color}`);
4248
-
4249
4351
  // Component title strip height on the slide, inches.
4250
4352
  const TITLE_H = 0.28;
4251
4353
  // Approximate table row height, inches — drives the rows-that-fit cap.
@@ -4260,6 +4362,13 @@ const PPTX_LEGEND_POS = {
4260
4362
  right: 'r',
4261
4363
  hidden: 't',
4262
4364
  };
4365
+ // What a waterfall's series are called in PowerPoint's data sheet and on hover.
4366
+ // The legend stays off, as it does on screen.
4367
+ const WATERFALL_SERIES_NAMES = {
4368
+ rise: 'Increase',
4369
+ fall: 'Decrease',
4370
+ total: 'Total',
4371
+ };
4263
4372
  // Builds the PowerPoint deck client-side with pptxgenjs. Charts are NATIVE
4264
4373
  // editable PowerPoint charts (slide.addChart writes real chart XML with an
4265
4374
  // embedded data workbook — "Edit Data" works), never images. The page IS the
@@ -4406,6 +4515,10 @@ class ReportPptxService {
4406
4515
  _renderChart(pptx, slide, component, data, x, y, w, h) {
4407
4516
  const config = component.config;
4408
4517
  const chartType = config.chartType ?? 'bar';
4518
+ if (chartType === 'waterfall') {
4519
+ this._renderWaterfall(pptx, slide, component, data, x, y, w, h);
4520
+ return;
4521
+ }
4409
4522
  const extracted = extractChartSeries(component, data);
4410
4523
  const typeMap = {
4411
4524
  bar: pptx.ChartType.bar,
@@ -4416,12 +4529,7 @@ class ReportPptxService {
4416
4529
  };
4417
4530
  const pie = chartType === 'pie' || chartType === 'donut';
4418
4531
  const colorBy = isColorBy(config);
4419
- // Compact period labels match the on-screen axis (year only when the data
4420
- // spans years); PowerPoint can't do a styled second line, so it's inline.
4421
- const granularity = data.granularity;
4422
- const labels = granularity
4423
- ? extracted.categories.map((category) => formatPeriodLabel(category, granularity, spansMultipleYears(extracted.categories)))
4424
- : extracted.categories;
4532
+ const labels = this._categoryLabels(extracted.categories, data.granularity);
4425
4533
  // Native chart data: one entry per series (pie charts take exactly one).
4426
4534
  // colorBy emits one series per category for the on-screen overlap; PowerPoint
4427
4535
  // has no equivalent overlap, so collapse them back into a single series (each
@@ -4453,14 +4561,7 @@ class ReportPptxService {
4453
4561
  barDir: config.orientation === 'horizontal' ? 'bar' : 'col',
4454
4562
  barGrouping: config.stacked ? 'stacked' : 'clustered',
4455
4563
  holeSize: chartType === 'donut' ? 55 : undefined,
4456
- catAxisLabelFontSize: 9,
4457
- valAxisLabelFontSize: 9,
4458
- showCatAxisTitle: !!config.xAxis?.label,
4459
- catAxisTitle: config.xAxis?.label,
4460
- catAxisTitleFontSize: 10,
4461
- showValAxisTitle: !!config.measures?.[0]?.label && !pie,
4462
- valAxisTitle: config.measures?.[0]?.label,
4463
- valAxisTitleFontSize: 10,
4564
+ ...this._axisTextOptions(config, pie),
4464
4565
  dataBorder: { pt: 0, color: 'FFFFFF' },
4465
4566
  };
4466
4567
  // The type each chartData entry is drawn as. A pie is one entry of its own
@@ -4493,6 +4594,75 @@ class ReportPptxService {
4493
4594
  };
4494
4595
  }), options);
4495
4596
  }
4597
+ // A waterfall is a stacked bar chart whose first series is invisible: it
4598
+ // lifts each bar to where its step starts. The series are cut differently
4599
+ // from the screen's; _waterfallSeries says why.
4600
+ _renderWaterfall(pptx, slide, component, data, x, y, w, h) {
4601
+ const config = component.config;
4602
+ const waterfall = extractWaterfall(component, data);
4603
+ const labels = this._categoryLabels(waterfall.steps.map((step) => step.category), data.granularity);
4604
+ const series = this._waterfallSeries(waterfall.steps);
4605
+ slide.addChart(pptx.ChartType.bar, series.map((entry) => ({ name: entry.name, labels, values: entry.values })), {
4606
+ x, y, w, h,
4607
+ chartColors: series.map((entry) => entry.color),
4608
+ showLegend: false,
4609
+ barDir: config.orientation === 'horizontal' ? 'bar' : 'col',
4610
+ barGrouping: 'stacked',
4611
+ ...this._axisTextOptions(config, false),
4612
+ // A running total can go below zero, where the category axis crosses;
4613
+ // its labels stay at the plot's edge instead of across the bars.
4614
+ catAxisLabelPos: 'low',
4615
+ });
4616
+ }
4617
+ // PowerPoint stacks positive and negative values apart, each from zero, so a
4618
+ // bar cannot simply sit on the total before it the way it does on screen.
4619
+ // Each step is cut at zero instead: the invisible base carries it to its end
4620
+ // nearer zero, and its kind's series carries the rest — one series above zero
4621
+ // and one below, because a step that crosses zero needs both. Every other
4622
+ // point is left blank: a zero there would still draw a zero-height bar, which
4623
+ // renders as a hairline. A kind's series that stays blank is dropped.
4624
+ _waterfallSeries(steps) {
4625
+ const base = { name: '', color: 'transparent', values: [] };
4626
+ const kinds = Object.keys(WATERFALL_SERIES_NAMES);
4627
+ const piece = (kind) => ({
4628
+ kind, name: WATERFALL_SERIES_NAMES[kind], color: REPORT_WATERFALL_COLORS[kind], values: [],
4629
+ });
4630
+ const above = kinds.map(piece);
4631
+ const below = kinds.map(piece);
4632
+ const blankZero = (value) => (value === 0 ? null : value);
4633
+ for (const step of steps) {
4634
+ const end = step.start + (step.change ?? 0);
4635
+ const low = Math.min(step.start, end);
4636
+ const high = Math.max(step.start, end);
4637
+ base.values.push(blankZero(low > 0 ? low : (high < 0 ? high : 0)));
4638
+ above.forEach((entry) => entry.values.push(entry.kind === step.kind ? blankZero(Math.max(high, 0) - Math.max(low, 0)) : null));
4639
+ below.forEach((entry) => entry.values.push(entry.kind === step.kind ? blankZero(Math.min(low, 0) - Math.min(high, 0)) : null));
4640
+ }
4641
+ return [base, ...[...above, ...below].filter((entry) => entry.values.some((value) => value !== null))];
4642
+ }
4643
+ // Compact period labels match the on-screen axis (year only when the data
4644
+ // spans years); PowerPoint can't do a styled second line, so it's inline.
4645
+ _categoryLabels(categories, granularity) {
4646
+ if (!granularity) {
4647
+ return categories;
4648
+ }
4649
+ const multiYear = spansMultipleYears(categories);
4650
+ return categories.map((category) => formatPeriodLabel(category, granularity, multiYear));
4651
+ }
4652
+ // The axis label and title settings every native chart shares. A pie has no
4653
+ // value axis to title.
4654
+ _axisTextOptions(config, pie) {
4655
+ return {
4656
+ catAxisLabelFontSize: 9,
4657
+ valAxisLabelFontSize: 9,
4658
+ showCatAxisTitle: !!config.xAxis?.label,
4659
+ catAxisTitle: config.xAxis?.label,
4660
+ catAxisTitleFontSize: 10,
4661
+ showValAxisTitle: !!config.measures?.[0]?.label && !pie,
4662
+ valAxisTitle: config.measures?.[0]?.label,
4663
+ valAxisTitleFontSize: 10,
4664
+ };
4665
+ }
4496
4666
  _renderTable(slide, component, data, x, y, w, h) {
4497
4667
  const config = component.config;
4498
4668
  const columns = config.columns?.length
@@ -5027,7 +5197,7 @@ class ReportComponent {
5027
5197
  ReportExportService,
5028
5198
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
5029
5199
  // this route's chunk.
5030
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CFkoNvo_.mjs').then((module) => module.default) }),
5200
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CtKYuRcT.mjs').then((module) => module.default) }),
5031
5201
  ], viewQueries: [{ propertyName: "_split", first: true, predicate: ["split"], descendants: true, static: true }, { propertyName: "_chatPanel", first: true, predicate: ["chatPanel"], descendants: true, read: ElementRef, static: true }, { propertyName: "_canvas", first: true, predicate: ReportCanvasComponent, descendants: true }], ngImport: i0, template: "<div\n #split\n class=\"report fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n style=\"min-height: 500px;\"\n basePath=\"reports\"\n [requestData]=\"{ reportId: selected?.id ?? null }\"\n [introMessage]=\"introMessage\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n <div class=\"viewer fs-flex fs-column\">\n <div class=\"fs-row.align-center.gap-sm\">\n <fs-autocomplete-chips\n class=\"fs-flex\"\n [fetch]=\"fetchReports\"\n [(ngModel)]=\"selected\"\n [disabled]=\"loadingReports\"\n [padless]=\"true\"\n [multiple]=\"false\"\n [fetchOnFocus]=\"true\"\n (ngModelChange)=\"reportChange($event)\"\n placeholder=\"Report\"\n name=\"report\">\n <ng-template\n fsAutocompleteChipsTemplate\n let-object=\"object\">\n {{ object.name }}\n </ng-template>\n <ng-template\n fsAutocompleteChipsStatic\n (click)=\"createReport()\">\n Create Report\n </ng-template>\n </fs-autocomplete-chips>\n @if (report && editMode) {\n <!-- What layout editing can add or switch to, and the way out of it \u2014\n the same outlined buttons as the fs-ai-report toolbar. -->\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"master-toggle\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n }\n @if (report) {\n <fs-menu>\n <ng-template\n fs-menu-item\n (click)=\"reportSettings()\">\n <mat-icon>\n tune\n </mat-icon>\n Report settings\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"toggleEditMode()\"\n [disabled]=\"editMode\">\n <mat-icon>\n dashboard_customize\n </mat-icon>\n {{ editMode ? 'Done editing layout' : 'Edit layout' }}\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n <mat-icon>\n slideshow\n </mat-icon>\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n <mat-icon>\n picture_as_pdf\n </mat-icon>\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data\n </ng-template>\n @if ((report?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n <mat-icon>\n table_view\n </mat-icon>\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n }\n </div>\n @if (report) {\n <!-- Report-level filters only (the report's actions live in the menu\n above). fs-filter reads its config once at init, so it's keyed on the\n filter signature: when the report-level filter set changes the block\n is recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters) {\n @for (key of [reportFilterKey]; track key) {\n <fs-filter [config]=\"reportFilterConfig\"></fs-filter>\n }\n }\n <app-report-canvas\n [report]=\"report\"\n [editMode]=\"editMode\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\">\n </app-report-canvas>\n }\n </div>\n</div>", styles: [".report{height:100%;min-height:500px}.report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:500px;min-width:0;border:none}.report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.report .viewer fs-filter{flex:0 0 auto;margin-top:10px;margin-bottom:0}.report .viewer ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.report .resizer:before{content:\"\";width:0px;background:#0000001f;transition:width .12s ease,background-color .12s ease}.report .resizer:hover:before{width:3px;background:var(--brand-primary-color)}.report.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.report.resizing .resizer:before{width:3px;background:var(--brand-primary-color)}.report.resizing .chat,.report.resizing .viewer{pointer-events:none}::ng-deep body.body-report-reports .mat-mdc-card-content{display:flex;flex-direction:column;box-sizing:border-box}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel{flex:1;min-height:0}::ng-deep body.body-report-reports .mat-mdc-card-content mat-tab-nav-panel router-outlet+ng-component{height:100%}.master-toggle.active{background:#eceff3}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: FsAutocompleteChipsModule }, { kind: "component", type: i2$3.FsAutocompleteChipsComponent, selector: "fs-autocomplete-chips", inputs: ["fetch", "appearance", "floatLabel", "readonly", "size", "label", "placeholder", "chipImage", "chipBackground", "chipColor", "chipIcon", "chipIconColor", "chipClass", "chipPadding", "shape", "hint", "allowText", "allowObject", "delay", "minPanelWidth", "maxPanelHeight", "validateText", "removable", "allowClear", "color", "background", "orderable", "padless", "initOnClick", "fetchOnFocus", "multiple", "multipleAdd", "confirm", "disabled", "groupBy", "panelWidth", "panelClass", "compareWith"], outputs: ["selected", "removed", "reordered", "clear", "panelOpened", "panelClosed"] }, { kind: "directive", type: i2$3.FsAutocompleteObjectDirective, selector: "[fsAutocompleteObject],[fsAutocompleteChipsTemplate]" }, { kind: "directive", type: i2$3.FsAutocompleteChipsStaticDirective, selector: "[fsAutocompleteChipsStatic]", inputs: ["show", "disable"], outputs: ["click", "selected"] }, { kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2$1.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$1.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "ready", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: ReportCanvasComponent, selector: "app-report-canvas", inputs: ["report", "editMode", "pageId"], outputs: ["componentSettings", "reportChanged", "pageChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5032
5202
  }
5033
5203
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ReportComponent, decorators: [{
@@ -5042,7 +5212,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
5042
5212
  ReportExportService,
5043
5213
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily with
5044
5214
  // this route's chunk.
5045
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CFkoNvo_.mjs').then((module) => module.default) }),
5215
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CtKYuRcT.mjs').then((module) => module.default) }),
5046
5216
  ], imports: [
5047
5217
  FormsModule,
5048
5218
  FsAutocompleteChipsModule,
@@ -5270,6 +5440,9 @@ class SemanticDatabaseComponent {
5270
5440
  static NAME_SUFFIX = 'Model';
5271
5441
  // null until loaded — gates the tab behind the skeleton.
5272
5442
  status = signal(null);
5443
+ // The status request failed. Settles the skeleton and shows why; the Build
5444
+ // action never depends on the status, so a build can still be started.
5445
+ statusError = signal(false);
5273
5446
  // Free-text filter over the unit list — at a few hundred rows the accordion
5274
5447
  // is only usable if you can narrow it.
5275
5448
  filter = signal('');
@@ -5277,6 +5450,9 @@ class SemanticDatabaseComponent {
5277
5450
  building = signal(false);
5278
5451
  buildActive = computed(() => this.building()
5279
5452
  || SemanticDatabaseComponent.ACTIVE_STATES.includes(this.status()?.build?.state));
5453
+ // A semantic database has been published before, so a build replaces it.
5454
+ // False while the status is unloaded or failed to load.
5455
+ rebuild = computed(() => !!this.status()?.detail?.generatedAt);
5280
5456
  buildStateLabel = computed(() => SemanticDatabaseComponent.STATE_LABELS[this.status()?.build?.state] ?? '');
5281
5457
  // Whether the published semantic database came from a build that finished.
5282
5458
  //
@@ -5380,7 +5556,7 @@ class SemanticDatabaseComponent {
5380
5556
  // Confirm before kicking off a build — a rebuild replaces the current
5381
5557
  // semantic database, so it should never fire on a stray click.
5382
5558
  build() {
5383
- const rebuild = !!this.status()?.detail?.generatedAt;
5559
+ const rebuild = this.rebuild();
5384
5560
  this._prompt.confirm({
5385
5561
  title: rebuild ? 'Rebuild semantic database' : 'Build semantic database',
5386
5562
  template: rebuild
@@ -5446,12 +5622,8 @@ class SemanticDatabaseComponent {
5446
5622
  this._refreshStatus();
5447
5623
  return;
5448
5624
  }
5449
- this._reportData.semanticBuildStatus()
5450
- .pipe(takeUntilDestroyed(this._destroyRef))
5451
- .subscribe((status) => {
5452
- this.status.set(status);
5453
- this._announceOutcome(status);
5454
- });
5625
+ this._loadStatus()
5626
+ .subscribe((status) => this._announceOutcome(status));
5455
5627
  }
5456
5628
  /**
5457
5629
  * The real reason a broken build stream broke, from what the server actually
@@ -5550,12 +5722,23 @@ class SemanticDatabaseComponent {
5550
5722
  : shortName;
5551
5723
  }
5552
5724
  _refreshStatus() {
5553
- this._reportData.semanticBuildStatus()
5554
- .pipe(takeUntilDestroyed(this._destroyRef))
5555
- .subscribe((status) => this.status.set(status));
5725
+ this._loadStatus()
5726
+ .subscribe();
5727
+ }
5728
+ // Fetch the status into the tab. A failure flags statusError and completes
5729
+ // quietly, so the tab settles and callers never see the error.
5730
+ _loadStatus() {
5731
+ return this._reportData.semanticBuildStatus()
5732
+ .pipe(tap((status) => {
5733
+ this.statusError.set(false);
5734
+ this.status.set(status);
5735
+ }), catchError(() => {
5736
+ this.statusError.set(true);
5737
+ return EMPTY;
5738
+ }), takeUntilDestroyed(this._destroyRef));
5556
5739
  }
5557
5740
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SemanticDatabaseComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
5558
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: SemanticDatabaseComponent, isStandalone: true, selector: "fs-ai-report-global-settings-semantic-database", ngImport: i0, template: "<div class=\"fs-column tab-body\">\n <p class=\"hint\">\n The semantic database is what the report AI uses to understand your data \u2014\n tables, relationships and the calculations in code. Rebuild it after\n significant application updates. The build runs in the background; progress\n appears in the dock at the bottom right and this dialog can be closed while\n it runs.\n </p>\n\n <div *fsSkeleton=\"status()\">\n @if (status(); as buildStatus) {\n <div class=\"build-status\">\n @if (buildStatus.detail.generatedAt) {\n <div class=\"stat\">\n <span class=\"label\">Last built</span>\n <fs-date\n [date]=\"buildStatus.detail.generatedAt\"\n format=\"date-time\">\n </fs-date>\n </div>\n <div class=\"stat\">\n <span class=\"label\">Coverage</span>\n <span>{{ coverage() }}</span>\n </div>\n } @else {\n <fs-message-warning>\n The semantic database has not been built yet \u2014 the report AI can't\n answer data questions until it is. Run the first build.\n </fs-message-warning>\n }\n @if (buildStatus.build) {\n <div class=\"stat\">\n <span class=\"label\">Last build run</span>\n <span>\n {{ buildStateLabel() }}\n @if (buildStatus.build.message) {\n \u2014 {{ buildStatus.build.message }}\n }\n </span>\n </div>\n }\n\n <!-- Everything above is true about what was published, and on its own it\n reads as a finished build: the structural half publishes BEFORE the\n AI function pass, so a run that died in that pass still stamps a\n date and still fills in the whole coverage line. This is the only\n line that says the build never got to the end. -->\n @if (buildStatus.detail.generatedAt && !buildComplete()) {\n <fs-message-warning>\n The last build did not finish, so this semantic database is incomplete \u2014\n {{ incompleteEntityCount() }} of {{ buildStatus.detail.entityCount }}\n entities have no code-function knowledge. The report AI answers from what\n is here and cannot see the rest. Rebuild to complete it.\n </fs-message-warning>\n }\n </div>\n\n <!-- Everything the build read, in one list: entities (a table + a model +\n a handler) and standalone code classes (domain logic with no table).\n The AI reads code from both, so splitting them into two sections\n would hide half of what was built. Each row drills into its shape,\n enum values, computed fields and cached function counts \u2014 the counts\n link into the functions dialog. -->\n @if (units().length) {\n <h2>Included ({{ units().length }})</h2>\n <div class=\"subheading\">\n Everything the report AI was built from. Entities can be queried;\n code classes are logic it reads and translates into SQL.\n </div>\n\n <input\n class=\"unit-filter\"\n type=\"search\"\n placeholder=\"Filter by name, class or key\u2026\"\n [value]=\"filter()\"\n (input)=\"setFilter($any($event.target).value)\">\n\n <mat-accordion class=\"entities\">\n @for (unit of filteredUnits(); track unit.key) {\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title>{{ unit.name }}</mat-panel-title>\n <mat-panel-description>\n <div class=\"stage-chips\">\n @if (!unit.hasTable) {\n <fs-chip size=\"tiny\" matTooltip=\"Domain logic with no table \u2014 read, never queried\">\n code\n </fs-chip>\n }\n <!-- Silence means built. A chip here means an interrupted\n build left this row owing content. -->\n @for (stage of pendingStages(unit); track stage) {\n <fs-chip\n size=\"tiny\"\n [backgroundColor]=\"pendingColor()\"\n color=\"#ffffff\"\n matTooltip=\"Needs building\">\n {{ stage }}\n </fs-chip>\n }\n </div>\n </mat-panel-description>\n </mat-expansion-panel-header>\n\n <!-- Deferred: the body only exists once expanded. -->\n <ng-template matExpansionPanelContent>\n <div class=\"entity-detail\">\n @if (unit.model) {\n <div class=\"stat\">\n <span class=\"label\">Class</span>\n <span>{{ unit.model }}</span>\n </div>\n }\n @if (unit.entity; as entity) {\n <div class=\"stat\">\n <span class=\"label\">Table</span>\n <span>{{ entity.table }}</span>\n </div>\n <!-- The handle the report AI addresses this entity by in\n describe_entity / compile_query. -->\n <div class=\"stat\">\n <span class=\"label\">Entity key</span>\n <span>{{ entity.key }}</span>\n </div>\n <div class=\"stat\">\n <span class=\"label\">Columns</span>\n <span>{{ entity.columns }}</span>\n </div>\n <div class=\"stat\">\n <span class=\"label\">Relationships</span>\n <span>{{ entity.relationships }}</span>\n </div>\n @if (entity.enums.length) {\n <div class=\"stat\">\n <span class=\"label\">Enumeration values</span>\n <div class=\"pair-list\">\n @for (enumColumn of entity.enums; track enumColumn.column) {\n <div class=\"pair-row\">\n <span class=\"pair-name\">{{ enumColumn.column }}</span>\n <span>{{ enumColumn.values.join(', ') }}</span>\n </div>\n }\n </div>\n </div>\n }\n @if (entity.computedFields.length) {\n <div class=\"stat\">\n <span class=\"label\">Computed fields</span>\n <span>{{ entity.computedFields.join(', ') }}</span>\n </div>\n }\n <div class=\"stat\">\n <span class=\"label\">Model functions</span>\n @if (entity.modelFunctionCount) {\n <a\n class=\"count-link\"\n (click)=\"openFunctions(unit, 'model')\">\n {{ entity.modelFunctionCount }}\n </a>\n } @else {\n <span>0</span>\n }\n </div>\n <div class=\"stat\">\n <span class=\"label\">Handler functions</span>\n @if (entity.handlerFunctionCount) {\n <a\n class=\"count-link\"\n (click)=\"openFunctions(unit, 'handler')\">\n {{ entity.handlerFunctionCount }}\n </a>\n } @else {\n <span>0</span>\n }\n </div>\n } @else {\n <div class=\"stat\">\n <span class=\"label\">Code functions</span>\n @if (unit.functionCount) {\n <a\n class=\"count-link\"\n (click)=\"openFunctions(unit, 'class')\">\n {{ unit.functionCount }}\n </a>\n } @else {\n <span>0</span>\n }\n </div>\n }\n </div>\n </ng-template>\n </mat-expansion-panel>\n }\n </mat-accordion>\n\n @if (!filteredUnits().length) {\n <div class=\"subheading\">Nothing matches \u201C{{ filter() }}\u201D.</div>\n }\n }\n\n <!-- The other half of the picture: tables the AI can't reach. Excluded\n ones have no model; unlinked junctions have a composite primary key\n but were folded into no relationship, so nothing can navigate to\n them. Both are invisible by omission \u2014 no error, just a capability\n that silently isn't there. Collapsed by default: the excluded list is\n long and mostly framework plumbing. -->\n @if (hasCatalog()) {\n <h2>Not in the semantic database</h2>\n <div class=\"subheading\">\n Tables the report AI can't see. Give one a model to promote it to an\n entity, then rebuild.\n </div>\n <mat-accordion class=\"catalog\">\n @if (excludedTables().length) {\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title>\n Tables excluded ({{ excludedTables().length }})\n </mat-panel-title>\n </mat-expansion-panel-header>\n\n <!-- Deferred: the body only exists once expanded. -->\n <ng-template matExpansionPanelContent>\n <div class=\"pair-list\">\n @for (table of excludedTables(); track table.table) {\n <div class=\"pair-row\">\n <span class=\"pair-name\">{{ table.table }}</span>\n <span>{{ table.reason }}</span>\n </div>\n }\n </div>\n </ng-template>\n </mat-expansion-panel>\n }\n @if (unlinkedJunctions().length) {\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title>\n Junctions not linked ({{ unlinkedJunctions().length }})\n </mat-panel-title>\n </mat-expansion-panel-header>\n\n <ng-template matExpansionPanelContent>\n <p class=\"small\">\n Link tables the build couldn't fold into a relationship \u2014 so\n neither they nor what they link can be reported on.\n </p>\n <div class=\"pair-list\">\n @for (table of unlinkedJunctions(); track table.table) {\n <div class=\"pair-row\">\n <span class=\"pair-name\">{{ table.table }}</span>\n <span>{{ table.reason }}</span>\n </div>\n }\n </div>\n </ng-template>\n </mat-expansion-panel>\n }\n </mat-accordion>\n }\n }\n </div>\n</div>\n", styles: [".hint{margin:0 0 12px;color:#0009;font-size:13px;line-height:1.5}.stat{display:flex;align-items:baseline;gap:12px}.stat>.label{flex:0 0 150px;white-space:nowrap;color:#0009}.build-status{display:flex;flex-direction:column;gap:8px}.pair-list{display:flex;flex-direction:column;gap:3px}.pair-row{display:flex;gap:12px}.pair-name{flex:0 0 140px;white-space:nowrap;color:#0009}.unit-filter{width:100%;box-sizing:border-box;margin:8px 0 4px;padding:6px 10px;border:1px solid rgba(0,0,0,.2);border-radius:4px;font:inherit}.unit-filter:focus{outline:none;border-color:#00000080}.entities{display:block;padding:3px}.entities mat-panel-description{justify-content:flex-end;margin-right:8px}.entities .stage-chips{display:flex;gap:4px}.entities .entity-detail{display:flex;flex-direction:column;gap:8px}.catalog{display:block;padding:3px}.catalog .pair-name{flex-basis:220px}\n"], dependencies: [{ kind: "ngmodule", type: MatExpansionModule }, { kind: "directive", type: i1$5.MatAccordion, selector: "mat-accordion", inputs: ["hideToggle", "displayMode", "togglePosition"], exportAs: ["matAccordion"] }, { kind: "component", type: i1$5.MatExpansionPanel, selector: "mat-expansion-panel", inputs: ["hideToggle", "togglePosition"], outputs: ["afterExpand", "afterCollapse"], exportAs: ["matExpansionPanel"] }, { kind: "component", type: i1$5.MatExpansionPanelHeader, selector: "mat-expansion-panel-header", inputs: ["expandedHeight", "collapsedHeight", "tabIndex"] }, { kind: "directive", type: i1$5.MatExpansionPanelTitle, selector: "mat-panel-title" }, { kind: "directive", type: i1$5.MatExpansionPanelDescription, selector: "mat-panel-description" }, { kind: "directive", type: i1$5.MatExpansionPanelContent, selector: "ng-template[matExpansionPanelContent]" }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2$5.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsChipModule }, { kind: "component", type: i1$4.FsChipComponent, selector: "fs-chip", inputs: ["selectable", "removable", "value", "maxWidth", "width", "backgroundColor", "borderColor", "color", "shape", "outlined", "outlineDash", "icon", "image", "selected", "disabled", "padding", "contrastColor", "size"], outputs: ["selectedToggled", "removed", "click"] }, { kind: "ngmodule", type: FsDateModule }, { kind: "component", type: i2$4.FsDateComponent, selector: "fs-date", inputs: ["date", "format", "timezone"] }, { kind: "ngmodule", type: FsMessageModule }, { kind: "component", type: i5$1.FsMessageWarningComponent, selector: "fs-message-warning" }, { kind: "ngmodule", type: FsSkeletonModule }, { kind: "directive", type: i4.FsSkeletonContentDirective, selector: "[fsSkeleton]", inputs: ["fsSkeleton", "fsSkeletonPattern"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5741
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: SemanticDatabaseComponent, isStandalone: true, selector: "fs-ai-report-global-settings-semantic-database", ngImport: i0, template: "<div class=\"fs-column tab-body\">\n <p class=\"hint\">\n The semantic database is what the report AI uses to understand your data \u2014\n tables, relationships and the calculations in code. Rebuild it after\n significant application updates. The build runs in the background; progress\n appears in the dock at the bottom right and this dialog can be closed while\n it runs.\n </p>\n\n <!-- A failed status load settles the skeleton too, so the tab says what\n happened instead of loading forever. -->\n @if (statusError()) {\n <fs-message-warning>\n The build status could not be loaded, so the last build and its coverage\n can't be shown. You can still run a build.\n </fs-message-warning>\n }\n\n <div *fsSkeleton=\"status() || statusError()\">\n @if (status(); as buildStatus) {\n <div class=\"build-status\">\n @if (buildStatus.detail.generatedAt) {\n <div class=\"stat\">\n <span class=\"label\">Last built</span>\n <fs-date\n [date]=\"buildStatus.detail.generatedAt\"\n format=\"date-time\">\n </fs-date>\n </div>\n <div class=\"stat\">\n <span class=\"label\">Coverage</span>\n <span>{{ coverage() }}</span>\n </div>\n } @else {\n <fs-message-warning>\n The semantic database has not been built yet \u2014 the report AI can't\n answer data questions until it is. Run the first build.\n </fs-message-warning>\n }\n @if (buildStatus.build) {\n <div class=\"stat\">\n <span class=\"label\">Last build run</span>\n <span>\n {{ buildStateLabel() }}\n @if (buildStatus.build.message) {\n \u2014 {{ buildStatus.build.message }}\n }\n </span>\n </div>\n }\n\n <!-- Everything above is true about what was published, and on its own it\n reads as a finished build: the structural half publishes BEFORE the\n AI function pass, so a run that died in that pass still stamps a\n date and still fills in the whole coverage line. This is the only\n line that says the build never got to the end. -->\n @if (buildStatus.detail.generatedAt && !buildComplete()) {\n <fs-message-warning>\n The last build did not finish, so this semantic database is incomplete \u2014\n {{ incompleteEntityCount() }} of {{ buildStatus.detail.entityCount }}\n entities have no code-function knowledge. The report AI answers from what\n is here and cannot see the rest. Rebuild to complete it.\n </fs-message-warning>\n }\n </div>\n\n <!-- Everything the build read, in one list: entities (a table + a model +\n a handler) and standalone code classes (domain logic with no table).\n The AI reads code from both, so splitting them into two sections\n would hide half of what was built. Each row drills into its shape,\n enum values, computed fields and cached function counts \u2014 the counts\n link into the functions dialog. -->\n @if (units().length) {\n <h2>Included ({{ units().length }})</h2>\n <div class=\"subheading\">\n Everything the report AI was built from. Entities can be queried;\n code classes are logic it reads and translates into SQL.\n </div>\n\n <input\n class=\"unit-filter\"\n type=\"search\"\n placeholder=\"Filter by name, class or key\u2026\"\n [value]=\"filter()\"\n (input)=\"setFilter($any($event.target).value)\">\n\n <mat-accordion class=\"entities\">\n @for (unit of filteredUnits(); track unit.key) {\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title>{{ unit.name }}</mat-panel-title>\n <mat-panel-description>\n <div class=\"stage-chips\">\n @if (!unit.hasTable) {\n <fs-chip size=\"tiny\" matTooltip=\"Domain logic with no table \u2014 read, never queried\">\n code\n </fs-chip>\n }\n <!-- Silence means built. A chip here means an interrupted\n build left this row owing content. -->\n @for (stage of pendingStages(unit); track stage) {\n <fs-chip\n size=\"tiny\"\n [backgroundColor]=\"pendingColor()\"\n color=\"#ffffff\"\n matTooltip=\"Needs building\">\n {{ stage }}\n </fs-chip>\n }\n </div>\n </mat-panel-description>\n </mat-expansion-panel-header>\n\n <!-- Deferred: the body only exists once expanded. -->\n <ng-template matExpansionPanelContent>\n <div class=\"entity-detail\">\n @if (unit.model) {\n <div class=\"stat\">\n <span class=\"label\">Class</span>\n <span>{{ unit.model }}</span>\n </div>\n }\n @if (unit.entity; as entity) {\n <div class=\"stat\">\n <span class=\"label\">Table</span>\n <span>{{ entity.table }}</span>\n </div>\n <!-- The handle the report AI addresses this entity by in\n describe_entity / compile_query. -->\n <div class=\"stat\">\n <span class=\"label\">Entity key</span>\n <span>{{ entity.key }}</span>\n </div>\n <div class=\"stat\">\n <span class=\"label\">Columns</span>\n <span>{{ entity.columns }}</span>\n </div>\n <div class=\"stat\">\n <span class=\"label\">Relationships</span>\n <span>{{ entity.relationships }}</span>\n </div>\n @if (entity.enums.length) {\n <div class=\"stat\">\n <span class=\"label\">Enumeration values</span>\n <div class=\"pair-list\">\n @for (enumColumn of entity.enums; track enumColumn.column) {\n <div class=\"pair-row\">\n <span class=\"pair-name\">{{ enumColumn.column }}</span>\n <span>{{ enumColumn.values.join(', ') }}</span>\n </div>\n }\n </div>\n </div>\n }\n @if (entity.computedFields.length) {\n <div class=\"stat\">\n <span class=\"label\">Computed fields</span>\n <span>{{ entity.computedFields.join(', ') }}</span>\n </div>\n }\n <div class=\"stat\">\n <span class=\"label\">Model functions</span>\n @if (entity.modelFunctionCount) {\n <a\n class=\"count-link\"\n (click)=\"openFunctions(unit, 'model')\">\n {{ entity.modelFunctionCount }}\n </a>\n } @else {\n <span>0</span>\n }\n </div>\n <div class=\"stat\">\n <span class=\"label\">Handler functions</span>\n @if (entity.handlerFunctionCount) {\n <a\n class=\"count-link\"\n (click)=\"openFunctions(unit, 'handler')\">\n {{ entity.handlerFunctionCount }}\n </a>\n } @else {\n <span>0</span>\n }\n </div>\n } @else {\n <div class=\"stat\">\n <span class=\"label\">Code functions</span>\n @if (unit.functionCount) {\n <a\n class=\"count-link\"\n (click)=\"openFunctions(unit, 'class')\">\n {{ unit.functionCount }}\n </a>\n } @else {\n <span>0</span>\n }\n </div>\n }\n </div>\n </ng-template>\n </mat-expansion-panel>\n }\n </mat-accordion>\n\n @if (!filteredUnits().length) {\n <div class=\"subheading\">Nothing matches \u201C{{ filter() }}\u201D.</div>\n }\n }\n\n <!-- The other half of the picture: tables the AI can't reach. Excluded\n ones have no model; unlinked junctions have a composite primary key\n but were folded into no relationship, so nothing can navigate to\n them. Both are invisible by omission \u2014 no error, just a capability\n that silently isn't there. Collapsed by default: the excluded list is\n long and mostly framework plumbing. -->\n @if (hasCatalog()) {\n <h2>Not in the semantic database</h2>\n <div class=\"subheading\">\n Tables the report AI can't see. Give one a model to promote it to an\n entity, then rebuild.\n </div>\n <mat-accordion class=\"catalog\">\n @if (excludedTables().length) {\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title>\n Tables excluded ({{ excludedTables().length }})\n </mat-panel-title>\n </mat-expansion-panel-header>\n\n <!-- Deferred: the body only exists once expanded. -->\n <ng-template matExpansionPanelContent>\n <div class=\"pair-list\">\n @for (table of excludedTables(); track table.table) {\n <div class=\"pair-row\">\n <span class=\"pair-name\">{{ table.table }}</span>\n <span>{{ table.reason }}</span>\n </div>\n }\n </div>\n </ng-template>\n </mat-expansion-panel>\n }\n @if (unlinkedJunctions().length) {\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title>\n Junctions not linked ({{ unlinkedJunctions().length }})\n </mat-panel-title>\n </mat-expansion-panel-header>\n\n <ng-template matExpansionPanelContent>\n <p class=\"small\">\n Link tables the build couldn't fold into a relationship \u2014 so\n neither they nor what they link can be reported on.\n </p>\n <div class=\"pair-list\">\n @for (table of unlinkedJunctions(); track table.table) {\n <div class=\"pair-row\">\n <span class=\"pair-name\">{{ table.table }}</span>\n <span>{{ table.reason }}</span>\n </div>\n }\n </div>\n </ng-template>\n </mat-expansion-panel>\n }\n </mat-accordion>\n }\n }\n </div>\n</div>\n", styles: [".hint{margin:0 0 12px;color:#0009;font-size:13px;line-height:1.5}.stat{display:flex;align-items:baseline;gap:12px}.stat>.label{flex:0 0 150px;white-space:nowrap;color:#0009}.build-status{display:flex;flex-direction:column;gap:8px}.pair-list{display:flex;flex-direction:column;gap:3px}.pair-row{display:flex;gap:12px}.pair-name{flex:0 0 140px;white-space:nowrap;color:#0009}.unit-filter{width:100%;box-sizing:border-box;margin:8px 0 4px;padding:6px 10px;border:1px solid rgba(0,0,0,.2);border-radius:4px;font:inherit}.unit-filter:focus{outline:none;border-color:#00000080}.entities{display:block;padding:3px}.entities mat-panel-description{justify-content:flex-end;margin-right:8px}.entities .stage-chips{display:flex;gap:4px}.entities .entity-detail{display:flex;flex-direction:column;gap:8px}.catalog{display:block;padding:3px}.catalog .pair-name{flex-basis:220px}\n"], dependencies: [{ kind: "ngmodule", type: MatExpansionModule }, { kind: "directive", type: i1$5.MatAccordion, selector: "mat-accordion", inputs: ["hideToggle", "displayMode", "togglePosition"], exportAs: ["matAccordion"] }, { kind: "component", type: i1$5.MatExpansionPanel, selector: "mat-expansion-panel", inputs: ["hideToggle", "togglePosition"], outputs: ["afterExpand", "afterCollapse"], exportAs: ["matExpansionPanel"] }, { kind: "component", type: i1$5.MatExpansionPanelHeader, selector: "mat-expansion-panel-header", inputs: ["expandedHeight", "collapsedHeight", "tabIndex"] }, { kind: "directive", type: i1$5.MatExpansionPanelTitle, selector: "mat-panel-title" }, { kind: "directive", type: i1$5.MatExpansionPanelDescription, selector: "mat-panel-description" }, { kind: "directive", type: i1$5.MatExpansionPanelContent, selector: "ng-template[matExpansionPanelContent]" }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2$5.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: FsChipModule }, { kind: "component", type: i1$4.FsChipComponent, selector: "fs-chip", inputs: ["selectable", "removable", "value", "maxWidth", "width", "backgroundColor", "borderColor", "color", "shape", "outlined", "outlineDash", "icon", "image", "selected", "disabled", "padding", "contrastColor", "size"], outputs: ["selectedToggled", "removed", "click"] }, { kind: "ngmodule", type: FsDateModule }, { kind: "component", type: i2$4.FsDateComponent, selector: "fs-date", inputs: ["date", "format", "timezone"] }, { kind: "ngmodule", type: FsMessageModule }, { kind: "component", type: i5$1.FsMessageWarningComponent, selector: "fs-message-warning" }, { kind: "ngmodule", type: FsSkeletonModule }, { kind: "directive", type: i4.FsSkeletonContentDirective, selector: "[fsSkeleton]", inputs: ["fsSkeleton", "fsSkeletonPattern"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5559
5742
  }
5560
5743
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SemanticDatabaseComponent, decorators: [{
5561
5744
  type: Component,
@@ -5566,7 +5749,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
5566
5749
  FsDateModule,
5567
5750
  FsMessageModule,
5568
5751
  FsSkeletonModule,
5569
- ], template: "<div class=\"fs-column tab-body\">\n <p class=\"hint\">\n The semantic database is what the report AI uses to understand your data \u2014\n tables, relationships and the calculations in code. Rebuild it after\n significant application updates. The build runs in the background; progress\n appears in the dock at the bottom right and this dialog can be closed while\n it runs.\n </p>\n\n <div *fsSkeleton=\"status()\">\n @if (status(); as buildStatus) {\n <div class=\"build-status\">\n @if (buildStatus.detail.generatedAt) {\n <div class=\"stat\">\n <span class=\"label\">Last built</span>\n <fs-date\n [date]=\"buildStatus.detail.generatedAt\"\n format=\"date-time\">\n </fs-date>\n </div>\n <div class=\"stat\">\n <span class=\"label\">Coverage</span>\n <span>{{ coverage() }}</span>\n </div>\n } @else {\n <fs-message-warning>\n The semantic database has not been built yet \u2014 the report AI can't\n answer data questions until it is. Run the first build.\n </fs-message-warning>\n }\n @if (buildStatus.build) {\n <div class=\"stat\">\n <span class=\"label\">Last build run</span>\n <span>\n {{ buildStateLabel() }}\n @if (buildStatus.build.message) {\n \u2014 {{ buildStatus.build.message }}\n }\n </span>\n </div>\n }\n\n <!-- Everything above is true about what was published, and on its own it\n reads as a finished build: the structural half publishes BEFORE the\n AI function pass, so a run that died in that pass still stamps a\n date and still fills in the whole coverage line. This is the only\n line that says the build never got to the end. -->\n @if (buildStatus.detail.generatedAt && !buildComplete()) {\n <fs-message-warning>\n The last build did not finish, so this semantic database is incomplete \u2014\n {{ incompleteEntityCount() }} of {{ buildStatus.detail.entityCount }}\n entities have no code-function knowledge. The report AI answers from what\n is here and cannot see the rest. Rebuild to complete it.\n </fs-message-warning>\n }\n </div>\n\n <!-- Everything the build read, in one list: entities (a table + a model +\n a handler) and standalone code classes (domain logic with no table).\n The AI reads code from both, so splitting them into two sections\n would hide half of what was built. Each row drills into its shape,\n enum values, computed fields and cached function counts \u2014 the counts\n link into the functions dialog. -->\n @if (units().length) {\n <h2>Included ({{ units().length }})</h2>\n <div class=\"subheading\">\n Everything the report AI was built from. Entities can be queried;\n code classes are logic it reads and translates into SQL.\n </div>\n\n <input\n class=\"unit-filter\"\n type=\"search\"\n placeholder=\"Filter by name, class or key\u2026\"\n [value]=\"filter()\"\n (input)=\"setFilter($any($event.target).value)\">\n\n <mat-accordion class=\"entities\">\n @for (unit of filteredUnits(); track unit.key) {\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title>{{ unit.name }}</mat-panel-title>\n <mat-panel-description>\n <div class=\"stage-chips\">\n @if (!unit.hasTable) {\n <fs-chip size=\"tiny\" matTooltip=\"Domain logic with no table \u2014 read, never queried\">\n code\n </fs-chip>\n }\n <!-- Silence means built. A chip here means an interrupted\n build left this row owing content. -->\n @for (stage of pendingStages(unit); track stage) {\n <fs-chip\n size=\"tiny\"\n [backgroundColor]=\"pendingColor()\"\n color=\"#ffffff\"\n matTooltip=\"Needs building\">\n {{ stage }}\n </fs-chip>\n }\n </div>\n </mat-panel-description>\n </mat-expansion-panel-header>\n\n <!-- Deferred: the body only exists once expanded. -->\n <ng-template matExpansionPanelContent>\n <div class=\"entity-detail\">\n @if (unit.model) {\n <div class=\"stat\">\n <span class=\"label\">Class</span>\n <span>{{ unit.model }}</span>\n </div>\n }\n @if (unit.entity; as entity) {\n <div class=\"stat\">\n <span class=\"label\">Table</span>\n <span>{{ entity.table }}</span>\n </div>\n <!-- The handle the report AI addresses this entity by in\n describe_entity / compile_query. -->\n <div class=\"stat\">\n <span class=\"label\">Entity key</span>\n <span>{{ entity.key }}</span>\n </div>\n <div class=\"stat\">\n <span class=\"label\">Columns</span>\n <span>{{ entity.columns }}</span>\n </div>\n <div class=\"stat\">\n <span class=\"label\">Relationships</span>\n <span>{{ entity.relationships }}</span>\n </div>\n @if (entity.enums.length) {\n <div class=\"stat\">\n <span class=\"label\">Enumeration values</span>\n <div class=\"pair-list\">\n @for (enumColumn of entity.enums; track enumColumn.column) {\n <div class=\"pair-row\">\n <span class=\"pair-name\">{{ enumColumn.column }}</span>\n <span>{{ enumColumn.values.join(', ') }}</span>\n </div>\n }\n </div>\n </div>\n }\n @if (entity.computedFields.length) {\n <div class=\"stat\">\n <span class=\"label\">Computed fields</span>\n <span>{{ entity.computedFields.join(', ') }}</span>\n </div>\n }\n <div class=\"stat\">\n <span class=\"label\">Model functions</span>\n @if (entity.modelFunctionCount) {\n <a\n class=\"count-link\"\n (click)=\"openFunctions(unit, 'model')\">\n {{ entity.modelFunctionCount }}\n </a>\n } @else {\n <span>0</span>\n }\n </div>\n <div class=\"stat\">\n <span class=\"label\">Handler functions</span>\n @if (entity.handlerFunctionCount) {\n <a\n class=\"count-link\"\n (click)=\"openFunctions(unit, 'handler')\">\n {{ entity.handlerFunctionCount }}\n </a>\n } @else {\n <span>0</span>\n }\n </div>\n } @else {\n <div class=\"stat\">\n <span class=\"label\">Code functions</span>\n @if (unit.functionCount) {\n <a\n class=\"count-link\"\n (click)=\"openFunctions(unit, 'class')\">\n {{ unit.functionCount }}\n </a>\n } @else {\n <span>0</span>\n }\n </div>\n }\n </div>\n </ng-template>\n </mat-expansion-panel>\n }\n </mat-accordion>\n\n @if (!filteredUnits().length) {\n <div class=\"subheading\">Nothing matches \u201C{{ filter() }}\u201D.</div>\n }\n }\n\n <!-- The other half of the picture: tables the AI can't reach. Excluded\n ones have no model; unlinked junctions have a composite primary key\n but were folded into no relationship, so nothing can navigate to\n them. Both are invisible by omission \u2014 no error, just a capability\n that silently isn't there. Collapsed by default: the excluded list is\n long and mostly framework plumbing. -->\n @if (hasCatalog()) {\n <h2>Not in the semantic database</h2>\n <div class=\"subheading\">\n Tables the report AI can't see. Give one a model to promote it to an\n entity, then rebuild.\n </div>\n <mat-accordion class=\"catalog\">\n @if (excludedTables().length) {\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title>\n Tables excluded ({{ excludedTables().length }})\n </mat-panel-title>\n </mat-expansion-panel-header>\n\n <!-- Deferred: the body only exists once expanded. -->\n <ng-template matExpansionPanelContent>\n <div class=\"pair-list\">\n @for (table of excludedTables(); track table.table) {\n <div class=\"pair-row\">\n <span class=\"pair-name\">{{ table.table }}</span>\n <span>{{ table.reason }}</span>\n </div>\n }\n </div>\n </ng-template>\n </mat-expansion-panel>\n }\n @if (unlinkedJunctions().length) {\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title>\n Junctions not linked ({{ unlinkedJunctions().length }})\n </mat-panel-title>\n </mat-expansion-panel-header>\n\n <ng-template matExpansionPanelContent>\n <p class=\"small\">\n Link tables the build couldn't fold into a relationship \u2014 so\n neither they nor what they link can be reported on.\n </p>\n <div class=\"pair-list\">\n @for (table of unlinkedJunctions(); track table.table) {\n <div class=\"pair-row\">\n <span class=\"pair-name\">{{ table.table }}</span>\n <span>{{ table.reason }}</span>\n </div>\n }\n </div>\n </ng-template>\n </mat-expansion-panel>\n }\n </mat-accordion>\n }\n }\n </div>\n</div>\n", styles: [".hint{margin:0 0 12px;color:#0009;font-size:13px;line-height:1.5}.stat{display:flex;align-items:baseline;gap:12px}.stat>.label{flex:0 0 150px;white-space:nowrap;color:#0009}.build-status{display:flex;flex-direction:column;gap:8px}.pair-list{display:flex;flex-direction:column;gap:3px}.pair-row{display:flex;gap:12px}.pair-name{flex:0 0 140px;white-space:nowrap;color:#0009}.unit-filter{width:100%;box-sizing:border-box;margin:8px 0 4px;padding:6px 10px;border:1px solid rgba(0,0,0,.2);border-radius:4px;font:inherit}.unit-filter:focus{outline:none;border-color:#00000080}.entities{display:block;padding:3px}.entities mat-panel-description{justify-content:flex-end;margin-right:8px}.entities .stage-chips{display:flex;gap:4px}.entities .entity-detail{display:flex;flex-direction:column;gap:8px}.catalog{display:block;padding:3px}.catalog .pair-name{flex-basis:220px}\n"] }]
5752
+ ], template: "<div class=\"fs-column tab-body\">\n <p class=\"hint\">\n The semantic database is what the report AI uses to understand your data \u2014\n tables, relationships and the calculations in code. Rebuild it after\n significant application updates. The build runs in the background; progress\n appears in the dock at the bottom right and this dialog can be closed while\n it runs.\n </p>\n\n <!-- A failed status load settles the skeleton too, so the tab says what\n happened instead of loading forever. -->\n @if (statusError()) {\n <fs-message-warning>\n The build status could not be loaded, so the last build and its coverage\n can't be shown. You can still run a build.\n </fs-message-warning>\n }\n\n <div *fsSkeleton=\"status() || statusError()\">\n @if (status(); as buildStatus) {\n <div class=\"build-status\">\n @if (buildStatus.detail.generatedAt) {\n <div class=\"stat\">\n <span class=\"label\">Last built</span>\n <fs-date\n [date]=\"buildStatus.detail.generatedAt\"\n format=\"date-time\">\n </fs-date>\n </div>\n <div class=\"stat\">\n <span class=\"label\">Coverage</span>\n <span>{{ coverage() }}</span>\n </div>\n } @else {\n <fs-message-warning>\n The semantic database has not been built yet \u2014 the report AI can't\n answer data questions until it is. Run the first build.\n </fs-message-warning>\n }\n @if (buildStatus.build) {\n <div class=\"stat\">\n <span class=\"label\">Last build run</span>\n <span>\n {{ buildStateLabel() }}\n @if (buildStatus.build.message) {\n \u2014 {{ buildStatus.build.message }}\n }\n </span>\n </div>\n }\n\n <!-- Everything above is true about what was published, and on its own it\n reads as a finished build: the structural half publishes BEFORE the\n AI function pass, so a run that died in that pass still stamps a\n date and still fills in the whole coverage line. This is the only\n line that says the build never got to the end. -->\n @if (buildStatus.detail.generatedAt && !buildComplete()) {\n <fs-message-warning>\n The last build did not finish, so this semantic database is incomplete \u2014\n {{ incompleteEntityCount() }} of {{ buildStatus.detail.entityCount }}\n entities have no code-function knowledge. The report AI answers from what\n is here and cannot see the rest. Rebuild to complete it.\n </fs-message-warning>\n }\n </div>\n\n <!-- Everything the build read, in one list: entities (a table + a model +\n a handler) and standalone code classes (domain logic with no table).\n The AI reads code from both, so splitting them into two sections\n would hide half of what was built. Each row drills into its shape,\n enum values, computed fields and cached function counts \u2014 the counts\n link into the functions dialog. -->\n @if (units().length) {\n <h2>Included ({{ units().length }})</h2>\n <div class=\"subheading\">\n Everything the report AI was built from. Entities can be queried;\n code classes are logic it reads and translates into SQL.\n </div>\n\n <input\n class=\"unit-filter\"\n type=\"search\"\n placeholder=\"Filter by name, class or key\u2026\"\n [value]=\"filter()\"\n (input)=\"setFilter($any($event.target).value)\">\n\n <mat-accordion class=\"entities\">\n @for (unit of filteredUnits(); track unit.key) {\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title>{{ unit.name }}</mat-panel-title>\n <mat-panel-description>\n <div class=\"stage-chips\">\n @if (!unit.hasTable) {\n <fs-chip size=\"tiny\" matTooltip=\"Domain logic with no table \u2014 read, never queried\">\n code\n </fs-chip>\n }\n <!-- Silence means built. A chip here means an interrupted\n build left this row owing content. -->\n @for (stage of pendingStages(unit); track stage) {\n <fs-chip\n size=\"tiny\"\n [backgroundColor]=\"pendingColor()\"\n color=\"#ffffff\"\n matTooltip=\"Needs building\">\n {{ stage }}\n </fs-chip>\n }\n </div>\n </mat-panel-description>\n </mat-expansion-panel-header>\n\n <!-- Deferred: the body only exists once expanded. -->\n <ng-template matExpansionPanelContent>\n <div class=\"entity-detail\">\n @if (unit.model) {\n <div class=\"stat\">\n <span class=\"label\">Class</span>\n <span>{{ unit.model }}</span>\n </div>\n }\n @if (unit.entity; as entity) {\n <div class=\"stat\">\n <span class=\"label\">Table</span>\n <span>{{ entity.table }}</span>\n </div>\n <!-- The handle the report AI addresses this entity by in\n describe_entity / compile_query. -->\n <div class=\"stat\">\n <span class=\"label\">Entity key</span>\n <span>{{ entity.key }}</span>\n </div>\n <div class=\"stat\">\n <span class=\"label\">Columns</span>\n <span>{{ entity.columns }}</span>\n </div>\n <div class=\"stat\">\n <span class=\"label\">Relationships</span>\n <span>{{ entity.relationships }}</span>\n </div>\n @if (entity.enums.length) {\n <div class=\"stat\">\n <span class=\"label\">Enumeration values</span>\n <div class=\"pair-list\">\n @for (enumColumn of entity.enums; track enumColumn.column) {\n <div class=\"pair-row\">\n <span class=\"pair-name\">{{ enumColumn.column }}</span>\n <span>{{ enumColumn.values.join(', ') }}</span>\n </div>\n }\n </div>\n </div>\n }\n @if (entity.computedFields.length) {\n <div class=\"stat\">\n <span class=\"label\">Computed fields</span>\n <span>{{ entity.computedFields.join(', ') }}</span>\n </div>\n }\n <div class=\"stat\">\n <span class=\"label\">Model functions</span>\n @if (entity.modelFunctionCount) {\n <a\n class=\"count-link\"\n (click)=\"openFunctions(unit, 'model')\">\n {{ entity.modelFunctionCount }}\n </a>\n } @else {\n <span>0</span>\n }\n </div>\n <div class=\"stat\">\n <span class=\"label\">Handler functions</span>\n @if (entity.handlerFunctionCount) {\n <a\n class=\"count-link\"\n (click)=\"openFunctions(unit, 'handler')\">\n {{ entity.handlerFunctionCount }}\n </a>\n } @else {\n <span>0</span>\n }\n </div>\n } @else {\n <div class=\"stat\">\n <span class=\"label\">Code functions</span>\n @if (unit.functionCount) {\n <a\n class=\"count-link\"\n (click)=\"openFunctions(unit, 'class')\">\n {{ unit.functionCount }}\n </a>\n } @else {\n <span>0</span>\n }\n </div>\n }\n </div>\n </ng-template>\n </mat-expansion-panel>\n }\n </mat-accordion>\n\n @if (!filteredUnits().length) {\n <div class=\"subheading\">Nothing matches \u201C{{ filter() }}\u201D.</div>\n }\n }\n\n <!-- The other half of the picture: tables the AI can't reach. Excluded\n ones have no model; unlinked junctions have a composite primary key\n but were folded into no relationship, so nothing can navigate to\n them. Both are invisible by omission \u2014 no error, just a capability\n that silently isn't there. Collapsed by default: the excluded list is\n long and mostly framework plumbing. -->\n @if (hasCatalog()) {\n <h2>Not in the semantic database</h2>\n <div class=\"subheading\">\n Tables the report AI can't see. Give one a model to promote it to an\n entity, then rebuild.\n </div>\n <mat-accordion class=\"catalog\">\n @if (excludedTables().length) {\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title>\n Tables excluded ({{ excludedTables().length }})\n </mat-panel-title>\n </mat-expansion-panel-header>\n\n <!-- Deferred: the body only exists once expanded. -->\n <ng-template matExpansionPanelContent>\n <div class=\"pair-list\">\n @for (table of excludedTables(); track table.table) {\n <div class=\"pair-row\">\n <span class=\"pair-name\">{{ table.table }}</span>\n <span>{{ table.reason }}</span>\n </div>\n }\n </div>\n </ng-template>\n </mat-expansion-panel>\n }\n @if (unlinkedJunctions().length) {\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title>\n Junctions not linked ({{ unlinkedJunctions().length }})\n </mat-panel-title>\n </mat-expansion-panel-header>\n\n <ng-template matExpansionPanelContent>\n <p class=\"small\">\n Link tables the build couldn't fold into a relationship \u2014 so\n neither they nor what they link can be reported on.\n </p>\n <div class=\"pair-list\">\n @for (table of unlinkedJunctions(); track table.table) {\n <div class=\"pair-row\">\n <span class=\"pair-name\">{{ table.table }}</span>\n <span>{{ table.reason }}</span>\n </div>\n }\n </div>\n </ng-template>\n </mat-expansion-panel>\n }\n </mat-accordion>\n }\n }\n </div>\n</div>\n", styles: [".hint{margin:0 0 12px;color:#0009;font-size:13px;line-height:1.5}.stat{display:flex;align-items:baseline;gap:12px}.stat>.label{flex:0 0 150px;white-space:nowrap;color:#0009}.build-status{display:flex;flex-direction:column;gap:8px}.pair-list{display:flex;flex-direction:column;gap:3px}.pair-row{display:flex;gap:12px}.pair-name{flex:0 0 140px;white-space:nowrap;color:#0009}.unit-filter{width:100%;box-sizing:border-box;margin:8px 0 4px;padding:6px 10px;border:1px solid rgba(0,0,0,.2);border-radius:4px;font:inherit}.unit-filter:focus{outline:none;border-color:#00000080}.entities{display:block;padding:3px}.entities mat-panel-description{justify-content:flex-end;margin-right:8px}.entities .stage-chips{display:flex;gap:4px}.entities .entity-detail{display:flex;flex-direction:column;gap:8px}.catalog{display:block;padding:3px}.catalog .pair-name{flex-basis:220px}\n"] }]
5570
5753
  }], ctorParameters: () => [] });
5571
5754
 
5572
5755
  // Matches the backend cap (ReportsView::INSTRUCTIONS_MAX_LENGTH) — the document
@@ -5685,7 +5868,7 @@ class GlobalSettingsComponent {
5685
5868
  this.concurrency.set(settings.semanticBuildConcurrency);
5686
5869
  }
5687
5870
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: GlobalSettingsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
5688
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: GlobalSettingsComponent, isStandalone: true, selector: "ng-component", viewQueries: [{ propertyName: "semanticDatabase", first: true, predicate: SemanticDatabaseComponent, descendants: true, isSignal: true }], ngImport: i0, template: "<form\n *fsSkeletonForm=\"loaded()\"\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <fs-dialog-title>\n Report Settings\n </fs-dialog-title>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"AI Knowledge\"\n name=\"knowledge\">\n <fs-ai-report-global-settings-ai-knowledge\n [content]=\"content()\"\n (contentChange)=\"content.set($event)\"\n [config]=\"editorConfig\">\n </fs-ai-report-global-settings-ai-knowledge>\n </mat-tab>\n <mat-tab\n label=\"Semantic Database\"\n name=\"build\">\n <fs-ai-report-global-settings-semantic-database>\n </fs-ai-report-global-settings-semantic-database>\n </mat-tab>\n <mat-tab\n label=\"General\"\n name=\"general\">\n <fs-ai-report-global-settings-general\n [concurrency]=\"concurrency()\"\n (concurrencyChange)=\"concurrency.set($event)\"\n [min]=\"settings()!.semanticBuildConcurrencyMin\"\n [max]=\"settings()!.semanticBuildConcurrencyMax\">\n </fs-ai-report-global-settings-general>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <!-- The footer follows the selected tab. AI Knowledge projects its own\n Save | View History | Cancel row (fs-form-dialog-actions renders\n projected content after Save/Cancel, so the default pair can't keep\n View History beside Save); General uses the default Save/Cancel; the\n Semantic Database tab is a read/run surface, so it gets a primary\n Close plus its Build/Rebuild/Stop action. -->\n <fs-form-dialog-actions [save]=\"selectedTab === 'general'\">\n @if (selectedTab === 'knowledge') {\n <button\n type=\"submit\"\n mat-button\n color=\"primary\">\n Save\n </button>\n <button\n type=\"button\"\n mat-button\n (click)=\"openHistory()\">\n View History\n </button>\n <button\n type=\"button\"\n mat-button\n [mat-dialog-close]=\"null\">\n Cancel\n </button>\n }\n @if (selectedTab === 'build') {\n <button\n type=\"button\"\n mat-button\n color=\"primary\"\n [mat-dialog-close]=\"undefined\">\n Close\n </button>\n }\n @if (selectedTab === 'build' && semanticDatabase(); as db) {\n @if (db.buildActive()) {\n <button\n type=\"button\"\n mat-button\n color=\"warn\"\n (click)=\"db.stopBuild()\">\n Stop Build\n </button>\n } @else {\n @if (db.status(); as buildStatus) {\n <button\n type=\"button\"\n mat-button\n (click)=\"db.build()\">\n {{ buildStatus.detail.generatedAt ? 'Rebuild' : 'Build' }}\n </button>\n }\n }\n }\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>\n", styles: [":host{display:block}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: FsDialogModule }, { kind: "component", type: i3$2.FsDialogComponent, selector: "fs-dialog", inputs: ["mobileMode", "mobileButtonPlacement", "mobileWidth", "mode", "buttonLayout"] }, { kind: "component", type: i3$2.FsDialogTitleComponent, selector: "fs-dialog-title", inputs: ["close", "dockToggle", "back"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$2.FsFormDirective, selector: "[fsForm]", inputs: ["wrapperSelector", "messageSelector", "hintSelector", "labelSelector", "autocomplete", "shortcuts", "confirm", "confirmDialog", "confirmDrawer", "confirmBrowser", "dirtySubmitButton", "submit", "successDelay", "errorDelay", "deactivationGuard"], outputs: ["fsForm", "invalid", "valid", "submitted", "reseted", "cleared"], exportAs: ["fsForm"] }, { kind: "component", type: i2$2.FsFormDialogActionsComponent, selector: "fs-form-dialog-actions", inputs: ["save", "create", "close", "done", "closeData", "name"] }, { kind: "directive", type: i2$2.FsButtonDirective, selector: "[mat-raised-button],[mat-button],[mat-flat-button],[mat-stroked-button]", inputs: ["name", "dirtySubmit"] }, { kind: "ngmodule", type: FsSkeletonModule }, { kind: "directive", type: i4.FsSkeletonFormDirective, selector: "[fsSkeletonForm]", inputs: ["fsSkeletonForm", "fsSkeletonFormLines"] }, { kind: "ngmodule", type: FsTabsModule }, { kind: "directive", type: i5.FsTabsHeaderTabGroupDirective, selector: "mat-tab-group, matTabGroup, [matTabGroup]", inputs: ["orientation", "selected", "selectedData"], outputs: ["selectedChange", "selectedDataChange"], exportAs: ["fsTabsHeaderTabGroup"] }, { kind: "directive", type: i5.FsTabsTabDirective, selector: "mat-tab,matTab", inputs: ["name", "data"], exportAs: ["fsTabsTab"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "directive", type: MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: MatDialogClose, selector: "[mat-dialog-close], [matDialogClose]", inputs: ["aria-label", "type", "mat-dialog-close", "matDialogClose"], exportAs: ["matDialogClose"] }, { kind: "directive", type: MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i6.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass"], exportAs: ["matTab"] }, { kind: "component", type: i6.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: AiKnowledgeComponent, selector: "fs-ai-report-global-settings-ai-knowledge", inputs: ["content", "config"], outputs: ["contentChange"] }, { kind: "component", type: GeneralComponent, selector: "fs-ai-report-global-settings-general", inputs: ["concurrency", "min", "max"], outputs: ["concurrencyChange"] }, { kind: "component", type: SemanticDatabaseComponent, selector: "fs-ai-report-global-settings-semantic-database" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5871
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: GlobalSettingsComponent, isStandalone: true, selector: "ng-component", viewQueries: [{ propertyName: "semanticDatabase", first: true, predicate: SemanticDatabaseComponent, descendants: true, isSignal: true }], ngImport: i0, template: "<form\n *fsSkeletonForm=\"loaded()\"\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <fs-dialog-title>\n Report Settings\n </fs-dialog-title>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"AI Knowledge\"\n name=\"knowledge\">\n <fs-ai-report-global-settings-ai-knowledge\n [content]=\"content()\"\n (contentChange)=\"content.set($event)\"\n [config]=\"editorConfig\">\n </fs-ai-report-global-settings-ai-knowledge>\n </mat-tab>\n <mat-tab\n label=\"Semantic Database\"\n name=\"build\">\n <fs-ai-report-global-settings-semantic-database>\n </fs-ai-report-global-settings-semantic-database>\n </mat-tab>\n <mat-tab\n label=\"General\"\n name=\"general\">\n <fs-ai-report-global-settings-general\n [concurrency]=\"concurrency()\"\n (concurrencyChange)=\"concurrency.set($event)\"\n [min]=\"settings()!.semanticBuildConcurrencyMin\"\n [max]=\"settings()!.semanticBuildConcurrencyMax\">\n </fs-ai-report-global-settings-general>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <!-- The footer follows the selected tab. AI Knowledge projects its own\n Save | View History | Cancel row (fs-form-dialog-actions renders\n projected content after Save/Cancel, so the default pair can't keep\n View History beside Save); General uses the default Save/Cancel; the\n Semantic Database tab is a read/run surface, so it gets a primary\n Close plus its Build/Rebuild/Stop action. -->\n <fs-form-dialog-actions [save]=\"selectedTab === 'general'\">\n @if (selectedTab === 'knowledge') {\n <button\n type=\"submit\"\n mat-button\n color=\"primary\">\n Save\n </button>\n <button\n type=\"button\"\n mat-button\n (click)=\"openHistory()\">\n View History\n </button>\n <button\n type=\"button\"\n mat-button\n [mat-dialog-close]=\"null\">\n Cancel\n </button>\n }\n @if (selectedTab === 'build') {\n <button\n type=\"button\"\n mat-button\n color=\"primary\"\n [mat-dialog-close]=\"undefined\">\n Close\n </button>\n }\n @if (selectedTab === 'build' && semanticDatabase(); as db) {\n @if (db.buildActive()) {\n <button\n type=\"button\"\n mat-button\n color=\"warn\"\n (click)=\"db.stopBuild()\">\n Stop Build\n </button>\n } @else {\n <!-- Never gated on the status load: a status response that fails\n must not take the Build action away with it. -->\n <button\n type=\"button\"\n mat-button\n (click)=\"db.build()\">\n {{ db.rebuild() ? 'Rebuild' : 'Build' }}\n </button>\n }\n }\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>\n", styles: [":host{display:block}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: FsDialogModule }, { kind: "component", type: i3$2.FsDialogComponent, selector: "fs-dialog", inputs: ["mobileMode", "mobileButtonPlacement", "mobileWidth", "mode", "buttonLayout"] }, { kind: "component", type: i3$2.FsDialogTitleComponent, selector: "fs-dialog-title", inputs: ["close", "dockToggle", "back"] }, { kind: "ngmodule", type: FsFormModule }, { kind: "directive", type: i2$2.FsFormDirective, selector: "[fsForm]", inputs: ["wrapperSelector", "messageSelector", "hintSelector", "labelSelector", "autocomplete", "shortcuts", "confirm", "confirmDialog", "confirmDrawer", "confirmBrowser", "dirtySubmitButton", "submit", "successDelay", "errorDelay", "deactivationGuard"], outputs: ["fsForm", "invalid", "valid", "submitted", "reseted", "cleared"], exportAs: ["fsForm"] }, { kind: "component", type: i2$2.FsFormDialogActionsComponent, selector: "fs-form-dialog-actions", inputs: ["save", "create", "close", "done", "closeData", "name"] }, { kind: "directive", type: i2$2.FsButtonDirective, selector: "[mat-raised-button],[mat-button],[mat-flat-button],[mat-stroked-button]", inputs: ["name", "dirtySubmit"] }, { kind: "ngmodule", type: FsSkeletonModule }, { kind: "directive", type: i4.FsSkeletonFormDirective, selector: "[fsSkeletonForm]", inputs: ["fsSkeletonForm", "fsSkeletonFormLines"] }, { kind: "ngmodule", type: FsTabsModule }, { kind: "directive", type: i5.FsTabsHeaderTabGroupDirective, selector: "mat-tab-group, matTabGroup, [matTabGroup]", inputs: ["orientation", "selected", "selectedData"], outputs: ["selectedChange", "selectedDataChange"], exportAs: ["fsTabsHeaderTabGroup"] }, { kind: "directive", type: i5.FsTabsTabDirective, selector: "mat-tab,matTab", inputs: ["name", "data"], exportAs: ["fsTabsTab"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "directive", type: MatDialogActions, selector: "[mat-dialog-actions], mat-dialog-actions, [matDialogActions]", inputs: ["align"] }, { kind: "directive", type: MatDialogClose, selector: "[mat-dialog-close], [matDialogClose]", inputs: ["aria-label", "type", "mat-dialog-close", "matDialogClose"], exportAs: ["matDialogClose"] }, { kind: "directive", type: MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "ngmodule", type: MatTabsModule }, { kind: "component", type: i6.MatTab, selector: "mat-tab", inputs: ["disabled", "label", "aria-label", "aria-labelledby", "labelClass", "bodyClass"], exportAs: ["matTab"] }, { kind: "component", type: i6.MatTabGroup, selector: "mat-tab-group", inputs: ["color", "fitInkBarToContent", "mat-stretch-tabs", "dynamicHeight", "selectedIndex", "headerPosition", "animationDuration", "contentTabIndex", "disablePagination", "disableRipple", "preserveContent", "backgroundColor", "aria-label", "aria-labelledby"], outputs: ["selectedIndexChange", "focusChange", "animationDone", "selectedTabChange"], exportAs: ["matTabGroup"] }, { kind: "component", type: AiKnowledgeComponent, selector: "fs-ai-report-global-settings-ai-knowledge", inputs: ["content", "config"], outputs: ["contentChange"] }, { kind: "component", type: GeneralComponent, selector: "fs-ai-report-global-settings-general", inputs: ["concurrency", "min", "max"], outputs: ["concurrencyChange"] }, { kind: "component", type: SemanticDatabaseComponent, selector: "fs-ai-report-global-settings-semantic-database" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5689
5872
  }
5690
5873
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: GlobalSettingsComponent, decorators: [{
5691
5874
  type: Component,
@@ -5703,7 +5886,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
5703
5886
  AiKnowledgeComponent,
5704
5887
  GeneralComponent,
5705
5888
  SemanticDatabaseComponent,
5706
- ], template: "<form\n *fsSkeletonForm=\"loaded()\"\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <fs-dialog-title>\n Report Settings\n </fs-dialog-title>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"AI Knowledge\"\n name=\"knowledge\">\n <fs-ai-report-global-settings-ai-knowledge\n [content]=\"content()\"\n (contentChange)=\"content.set($event)\"\n [config]=\"editorConfig\">\n </fs-ai-report-global-settings-ai-knowledge>\n </mat-tab>\n <mat-tab\n label=\"Semantic Database\"\n name=\"build\">\n <fs-ai-report-global-settings-semantic-database>\n </fs-ai-report-global-settings-semantic-database>\n </mat-tab>\n <mat-tab\n label=\"General\"\n name=\"general\">\n <fs-ai-report-global-settings-general\n [concurrency]=\"concurrency()\"\n (concurrencyChange)=\"concurrency.set($event)\"\n [min]=\"settings()!.semanticBuildConcurrencyMin\"\n [max]=\"settings()!.semanticBuildConcurrencyMax\">\n </fs-ai-report-global-settings-general>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <!-- The footer follows the selected tab. AI Knowledge projects its own\n Save | View History | Cancel row (fs-form-dialog-actions renders\n projected content after Save/Cancel, so the default pair can't keep\n View History beside Save); General uses the default Save/Cancel; the\n Semantic Database tab is a read/run surface, so it gets a primary\n Close plus its Build/Rebuild/Stop action. -->\n <fs-form-dialog-actions [save]=\"selectedTab === 'general'\">\n @if (selectedTab === 'knowledge') {\n <button\n type=\"submit\"\n mat-button\n color=\"primary\">\n Save\n </button>\n <button\n type=\"button\"\n mat-button\n (click)=\"openHistory()\">\n View History\n </button>\n <button\n type=\"button\"\n mat-button\n [mat-dialog-close]=\"null\">\n Cancel\n </button>\n }\n @if (selectedTab === 'build') {\n <button\n type=\"button\"\n mat-button\n color=\"primary\"\n [mat-dialog-close]=\"undefined\">\n Close\n </button>\n }\n @if (selectedTab === 'build' && semanticDatabase(); as db) {\n @if (db.buildActive()) {\n <button\n type=\"button\"\n mat-button\n color=\"warn\"\n (click)=\"db.stopBuild()\">\n Stop Build\n </button>\n } @else {\n @if (db.status(); as buildStatus) {\n <button\n type=\"button\"\n mat-button\n (click)=\"db.build()\">\n {{ buildStatus.detail.generatedAt ? 'Rebuild' : 'Build' }}\n </button>\n }\n }\n }\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>\n", styles: [":host{display:block}\n"] }]
5889
+ ], template: "<form\n *fsSkeletonForm=\"loaded()\"\n fsForm\n [submit]=\"save\">\n <fs-dialog>\n <fs-dialog-title>\n Report Settings\n </fs-dialog-title>\n <mat-dialog-content>\n <mat-tab-group [(selected)]=\"selectedTab\">\n <mat-tab\n label=\"AI Knowledge\"\n name=\"knowledge\">\n <fs-ai-report-global-settings-ai-knowledge\n [content]=\"content()\"\n (contentChange)=\"content.set($event)\"\n [config]=\"editorConfig\">\n </fs-ai-report-global-settings-ai-knowledge>\n </mat-tab>\n <mat-tab\n label=\"Semantic Database\"\n name=\"build\">\n <fs-ai-report-global-settings-semantic-database>\n </fs-ai-report-global-settings-semantic-database>\n </mat-tab>\n <mat-tab\n label=\"General\"\n name=\"general\">\n <fs-ai-report-global-settings-general\n [concurrency]=\"concurrency()\"\n (concurrencyChange)=\"concurrency.set($event)\"\n [min]=\"settings()!.semanticBuildConcurrencyMin\"\n [max]=\"settings()!.semanticBuildConcurrencyMax\">\n </fs-ai-report-global-settings-general>\n </mat-tab>\n </mat-tab-group>\n </mat-dialog-content>\n <mat-dialog-actions>\n <!-- The footer follows the selected tab. AI Knowledge projects its own\n Save | View History | Cancel row (fs-form-dialog-actions renders\n projected content after Save/Cancel, so the default pair can't keep\n View History beside Save); General uses the default Save/Cancel; the\n Semantic Database tab is a read/run surface, so it gets a primary\n Close plus its Build/Rebuild/Stop action. -->\n <fs-form-dialog-actions [save]=\"selectedTab === 'general'\">\n @if (selectedTab === 'knowledge') {\n <button\n type=\"submit\"\n mat-button\n color=\"primary\">\n Save\n </button>\n <button\n type=\"button\"\n mat-button\n (click)=\"openHistory()\">\n View History\n </button>\n <button\n type=\"button\"\n mat-button\n [mat-dialog-close]=\"null\">\n Cancel\n </button>\n }\n @if (selectedTab === 'build') {\n <button\n type=\"button\"\n mat-button\n color=\"primary\"\n [mat-dialog-close]=\"undefined\">\n Close\n </button>\n }\n @if (selectedTab === 'build' && semanticDatabase(); as db) {\n @if (db.buildActive()) {\n <button\n type=\"button\"\n mat-button\n color=\"warn\"\n (click)=\"db.stopBuild()\">\n Stop Build\n </button>\n } @else {\n <!-- Never gated on the status load: a status response that fails\n must not take the Build action away with it. -->\n <button\n type=\"button\"\n mat-button\n (click)=\"db.build()\">\n {{ db.rebuild() ? 'Rebuild' : 'Build' }}\n </button>\n }\n }\n </fs-form-dialog-actions>\n </mat-dialog-actions>\n </fs-dialog>\n</form>\n", styles: [":host{display:block}\n"] }]
5707
5890
  }], ctorParameters: () => [] });
5708
5891
 
5709
5892
  // The reports listing — an fs-tree of folders and the reports filed into them.
@@ -6594,7 +6777,7 @@ class FsAiReportComponent {
6594
6777
  ReportPdfService,
6595
6778
  ReportExportService,
6596
6779
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
6597
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CFkoNvo_.mjs').then((module) => module.default) }),
6780
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CtKYuRcT.mjs').then((module) => module.default) }),
6598
6781
  ], viewQueries: [{ propertyName: "_split", first: true, predicate: ["split"], descendants: true, static: true }, { propertyName: "_chatPanel", first: true, predicate: ["chatPanel"], descendants: true, read: ElementRef, static: true }, { propertyName: "_canvas", first: true, predicate: ReportCanvasComponent, descendants: true }], ngImport: i0, template: "<div class=\"fs-ai-report fs-column\">\n\n <div class=\"fs-ai-report__header fs-row.align-center\">\n <h2 class=\"fs-ai-report__title fs-flex\">{{ report?.name }}</h2>\n\n @if (report) {\n <!-- Report-level filters, inline with the toolbar actions. fs-filter\n reads its config once at init, so it's keyed on the filter\n signature: when the report-level filter set changes the block is\n recreated and re-reads the rebuilt config. -->\n @if (reportHasFilters) {\n @for (key of [reportFilterKey]; track key) {\n <fs-filter\n class=\"fs-ai-report__filters\"\n [config]=\"reportFilterConfig\">\n </fs-filter>\n }\n }\n\n <!-- What layout editing can add or switch to, and the way out of it.\n One style for all three \u2014 outlined, icon and text. \"Header & footer\"\n is a toggle: it keeps its label and turns gray while it is on. -->\n @if (editMode) {\n <div class=\"fs-ai-report__edit-tools fs-row.align-center.gap-xs\">\n <button\n mat-stroked-button\n type=\"button\"\n matTooltip=\"Add a block of text\"\n (click)=\"addText()\">\n <mat-icon>title</mat-icon>\n Add text\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n class=\"fs-ai-report__master\"\n [class.active]=\"isMasterMode\"\n [matTooltip]=\"isMasterMode ? 'Back to editing this page' : 'Edit the header and footer shown on every page'\"\n (click)=\"toggleMasterMode()\">\n <mat-icon>web_asset</mat-icon>\n Header & footer\n </button>\n <button\n mat-stroked-button\n type=\"button\"\n (click)=\"toggleEditMode()\">\n <mat-icon>check</mat-icon>\n Done editing\n </button>\n </div>\n }\n\n <!-- One icon for both states: it reads as pressed while editing rather\n than swapping to something else. -->\n <button\n mat-icon-button\n type=\"button\"\n class=\"fs-ai-report__toggle\"\n [class.active]=\"editMode\"\n [matTooltip]=\"editMode ? 'Done editing' : 'Edit layout'\"\n (click)=\"toggleEditMode()\">\n <mat-icon>dashboard_customize</mat-icon>\n </button>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Export\"\n [fsMenuTriggerFor]=\"exportMenu\">\n <mat-icon>download</mat-icon>\n </button>\n <fs-menu #exportMenu>\n <ng-template\n fs-menu-item\n (click)=\"exportPowerpoint()\">\n Export PowerPoint\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportPdf()\">\n Export PDF\n </ng-template>\n <ng-template\n fs-menu-item\n (click)=\"exportData()\">\n Export Raw Data\n </ng-template>\n @if ((report?.pages?.length ?? 0) > 1) {\n <ng-template\n fs-menu-item\n (click)=\"exportData(true)\">\n Export Raw Data (This Page)\n </ng-template>\n }\n </fs-menu>\n\n <button\n mat-icon-button\n type=\"button\"\n matTooltip=\"Report settings\"\n (click)=\"reportSettings()\">\n <mat-icon>settings</mat-icon>\n </button>\n }\n </div>\n\n <div\n #split\n class=\"fs-ai-report__split fs-row.align-start\">\n <fs-ai-chat\n #chatPanel\n class=\"chat\"\n [basePath]=\"basePath()\"\n [requestData]=\"chatRequestData()\"\n [ready]=\"chatPageId() !== null\"\n (response)=\"onChatResponse($event)\">\n </fs-ai-chat>\n\n <div\n class=\"resizer\"\n (pointerdown)=\"onResizeStart($event)\">\n </div>\n\n <div class=\"viewer fs-flex fs-column\">\n @if (report) {\n <app-report-canvas\n [report]=\"report\"\n [editMode]=\"editMode\"\n [pageId]=\"pageId\"\n (componentSettings)=\"componentSettings($event)\"\n (reportChanged)=\"onCanvasReportChanged()\"\n (pageChanged)=\"onCanvasPageChanged($event)\">\n </app-report-canvas>\n }\n </div>\n </div>\n</div>\n", styles: [".fs-ai-report{height:100%;min-height:500px;display:flex;flex-direction:column}.fs-ai-report__header{flex:0 0 auto;margin-bottom:10px}.fs-ai-report__title{margin:0;min-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fs-ai-report__filters{flex:0 1 auto;min-width:0;display:block;margin:0 5px 0 0}.fs-ai-report__filters ::ng-deep .filter-bar-container{align-items:center!important}.fs-ai-report__filters ::ng-deep .mat-mdc-form-field-subscript-wrapper{display:none}.fs-ai-report__toggle.active{background:#e3ecf7;color:var(--brand-primary-color, #2196f3)}.fs-ai-report__master.active{background:#eceff3}.fs-ai-report__edit-tools{margin:0 5px}.fs-ai-report__split{flex:1 1 auto;min-height:0}.fs-ai-report .chat{display:block;flex:0 0 25%;width:100%;height:100%;min-height:400px;min-width:0;border:none}.fs-ai-report .viewer{display:flex;flex-direction:column;min-width:0;height:100%;overflow:hidden}.fs-ai-report .resizer{flex:0 0 11px;align-self:stretch;display:flex;justify-content:center;cursor:col-resize;touch-action:none;-webkit-user-select:none;user-select:none}.fs-ai-report .resizer:before{content:\"\";width:3px;border-radius:3px;background:transparent;transition:background-color .12s ease}.fs-ai-report .resizer:hover:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing{cursor:col-resize;-webkit-user-select:none;user-select:none}.fs-ai-report__split.resizing .resizer:before{background:var(--brand-primary-color)}.fs-ai-report__split.resizing .chat,.fs-ai-report__split.resizing .viewer{pointer-events:none}\n"], dependencies: [{ kind: "ngmodule", type: FsFilterModule }, { kind: "component", type: i2.FilterComponent, selector: "fs-filter", inputs: ["config"], outputs: ["closed", "opened", "ready"] }, { kind: "ngmodule", type: FsMenuModule }, { kind: "component", type: i2$1.FsMenuComponent, selector: "fs-menu", inputs: ["class", "buttonClass", "buttonType", "buttonColor"], outputs: ["opened", "closed"] }, { kind: "directive", type: i2$1.FsMenuItemDirective, selector: "fs-menu-group,[fs-menu-item]" }, { kind: "directive", type: i2$1.FsMenuTriggerDirective, selector: "[fsMenuTriggerFor]", inputs: ["fsMenuTriggerFor"] }, { kind: "component", type: FsAiChatComponent, selector: "fs-ai-chat", inputs: ["basePath", "requestData", "ready", "introMessage", "showThinking"], outputs: ["response", "showThinkingChange"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3$3.MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: i3$3.MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5$2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i2$5.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: ReportCanvasComponent, selector: "app-report-canvas", inputs: ["report", "editMode", "pageId"], outputs: ["componentSettings", "reportChanged", "pageChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6599
6782
  }
6600
6783
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FsAiReportComponent, decorators: [{
@@ -6608,7 +6791,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
6608
6791
  ReportPdfService,
6609
6792
  ReportExportService,
6610
6793
  // Tree-shaken ECharts core + the 'report' house theme, loaded lazily.
6611
- provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CFkoNvo_.mjs').then((module) => module.default) }),
6794
+ provideEchartsCore({ echarts: () => import('./firestitch-report-echarts-CtKYuRcT.mjs').then((module) => module.default) }),
6612
6795
  ], imports: [
6613
6796
  FsFilterModule,
6614
6797
  FsMenuModule,
@@ -6642,4 +6825,4 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
6642
6825
  */
6643
6826
 
6644
6827
  export { FsAiReportsComponent as F, GRANULARITY_OPTIONS as G, REPORT_CHART_COLORS_CSS as R, ReportComponent as a, FsAiReportComponent as b, ReportData as c, ReportService as d, ReportFilterStateService as e, ReportNodeType as f, ReportState as g };
6645
- //# sourceMappingURL=firestitch-report-firestitch-report-PZzkYYTD.mjs.map
6828
+ //# sourceMappingURL=firestitch-report-firestitch-report-Df1IXrEs.mjs.map