@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.
- package/app/reports/dialogs/global-settings/components/semantic-database/semantic-database.component.d.ts +3 -0
- package/app/reports/export/report-pptx.service.d.ts +4 -0
- package/app/reports/interfaces/report.interface.d.ts +2 -1
- package/app/reports/option-builder.d.ts +13 -1
- package/app/reports/text.d.ts +1 -0
- package/app/reports/theme/palette.d.ts +2 -0
- package/esm2022/app/reports/dialogs/global-settings/components/semantic-database/semantic-database.component.mjs +28 -14
- package/esm2022/app/reports/dialogs/global-settings/global-settings.component.mjs +3 -3
- package/esm2022/app/reports/export/report-pptx.service.mjs +85 -17
- package/esm2022/app/reports/interfaces/report.interface.mjs +1 -1
- package/esm2022/app/reports/option-builder.mjs +108 -11
- package/esm2022/app/reports/text.mjs +2 -2
- package/esm2022/app/reports/theme/palette.mjs +9 -1
- package/fesm2022/{firestitch-report-echarts-CFkoNvo_.mjs → firestitch-report-echarts-CtKYuRcT.mjs} +2 -2
- package/fesm2022/{firestitch-report-echarts-CFkoNvo_.mjs.map → firestitch-report-echarts-CtKYuRcT.mjs.map} +1 -1
- package/fesm2022/{firestitch-report-firestitch-report-PZzkYYTD.mjs → firestitch-report-firestitch-report-Df1IXrEs.mjs} +975 -792
- package/fesm2022/firestitch-report-firestitch-report-Df1IXrEs.mjs.map +1 -0
- package/fesm2022/firestitch-report.mjs +1 -1
- package/package.json +1 -1
- package/fesm2022/firestitch-report-firestitch-report-PZzkYYTD.mjs.map +0 -1
|
@@ -979,113 +979,413 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
979
979
|
type: Injectable
|
|
980
980
|
}] });
|
|
981
981
|
|
|
982
|
-
//
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
//
|
|
986
|
-
//
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
//
|
|
990
|
-
//
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1015
|
-
// the
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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, '&')
|
|
1238
|
+
.replace(/</g, '<')
|
|
1239
|
+
.replace(/>/g, '>')
|
|
1240
|
+
.replace(/"/g, '"');
|
|
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
|
|
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
|
-
|
|
1805
|
-
|
|
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
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
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
|
-
|
|
1815
|
-
|
|
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
|
-
|
|
1818
|
-
|
|
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
|
-
|
|
1821
|
-
|
|
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
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
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
|
|
1828
|
-
|
|
1829
|
-
|
|
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
|
-
|
|
1832
|
-
|
|
1713
|
+
onChartInit(chart) {
|
|
1714
|
+
this._chart = chart;
|
|
1833
1715
|
}
|
|
1834
|
-
|
|
1835
|
-
|
|
1716
|
+
// Called by the canvas when the box is resized in edit mode.
|
|
1717
|
+
resize() {
|
|
1718
|
+
this._chart?.resize();
|
|
1836
1719
|
}
|
|
1837
|
-
|
|
1838
|
-
|
|
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
|
-
|
|
1841
|
-
|
|
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
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
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
|
-
|
|
1848
|
-
|
|
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
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
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
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
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
|
-
//
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
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
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
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
|
-
|
|
1891
|
-
|
|
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, '&')
|
|
1899
|
-
.replace(/</g, '<')
|
|
1900
|
-
.replace(/>/g, '>')
|
|
1901
|
-
.replace(/"/g, '"');
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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 =
|
|
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.
|
|
5450
|
-
.
|
|
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.
|
|
5554
|
-
.
|
|
5555
|
-
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
6828
|
+
//# sourceMappingURL=firestitch-report-firestitch-report-Df1IXrEs.mjs.map
|