Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@
"any": "всички",
"yes": "да",
"no": "не",
"true": "Да",
"false": "Не",
"min": "мин",
"max": "макс",
"contains": "съдържа..."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@
"any": "any",
"yes": "yes",
"no": "no",
"true": "Yes",
"false": "No",
"min": "min",
"max": "max",
"contains": "contains..."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,16 +205,47 @@ document.addEventListener('alpine:init', () => {
const category = this.reportColumns.find(c => c.agg === 'NONE') || this.reportColumns[0];
let series = this.reportColumns.filter(c => c.agg && c.agg !== 'NONE');
if (!series.length) series = this.reportColumns.filter(c => c !== category);
const labels = this.rows.map(r => this.axisLabel(category ? r[category.key] : ''));
return {
labels,
series: series.map(c => ({ name: c.key, data: this.rows.map(r => Number(r[c.key]) || 0) })),
legend: series.length > 1,
};
const labels = this.rows.map(r => this.axisLabel(category ? r[category.key] : '', category));
const data = series.map(c => ({ name: c.key, data: this.rows.map(r => Number(r[c.key]) || 0) }));
// A circular chart slices the FIRST measure by the dimension, so the dimension values are named
// by the legend alone - it stays on whatever the series count - and each legend entry carries
// the slice's share of the measure total. Pie and doughnut additionally suppress the in-slice
// data label: the chart library prints the raw measure there with a percent sign, so a 336-hour
// sum reads "336%" (codbex/harmonia#113, fixed after 2.14.2). The share belongs in the legend
// either way.
if (this.chartType === 'pie' || this.chartType === 'doughnut') {
return { labels: this.shareLabels(labels, data[0]), series: data, legend: true, dataLabels: false };
}
if (this.chartType === 'polarArea') {
return { labels: this.shareLabels(labels, data[0]), series: data, legend: true };
}
return { labels, series: data, legend: data.length > 1 };
},

// month(date) buckets are YYYYMM integers (e.g. 202607) - render them as "2026-07".
axisLabel(v) {
// "Billable" -> "Billable - 62%": a slice label with its share of the measure total, so a circular
// chart's legend reads as a split. Left untouched when there is no positive total to share, and
// when the fetched rows are only a window on the result (the dashboard tile fetches a few) - a
// page-local share is not the report's, the same rule the table footer applies to its totals.
shareLabels(labels, measure) {
if (this.count > this.rows.length) return labels;
const values = (measure && measure.data) || [];
const total = values.reduce((sum, v) => sum + (v > 0 ? v : 0), 0);
if (!(total > 0)) return labels;
return labels.map((label, i) => {
const v = values[i];
if (!(v > 0)) return label;
const pct = (v / total) * 100;
return label + ' - ' + (pct < 10 ? pct.toFixed(1) : String(Math.round(pct))) + '%';
});
},

// month(date) buckets are YYYYMM integers (e.g. 202607) - render them as "2026-07"; a boolean
// dimension reads as Yes/No, never as the raw true/false.
axisLabel(v, meta) {
if (typeof v === 'boolean' || (meta && meta.kind === 'boolean' && v != null && v !== '')) {
const yes = v === true || v === 'true' || v === 1;
return yes ? T('application-core:shell.report.true', 'Yes') : T('application-core:shell.report.false', 'No');
}
const n = Number(v);
if (Number.isInteger(n) && n >= 190001 && n <= 999912 && (n % 100) >= 1 && (n % 100) <= 12) {
return Math.floor(n / 100) + '-' + String(n % 100).padStart(2, '0');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,30 @@ document.addEventListener('alpine:init', () => {
#end
#end
];
#if($layoutType == "REPORT_PIE" || $layoutType == "REPORT_DOUGHNUT" || $layoutType == "REPORT_POLARAREA")
// A circular chart's slices ARE the label column's values, named by the legend alone, so each
// label carries its share of the first series' total.
const values = (series[0] && series[0].data) || [];
const total = values.reduce((sum, v) => sum + (v > 0 ? v : 0), 0);
const sliceLabels = total > 0
? labels.map((label, i) => {
const v = values[i];
if (!(v > 0)) return label;
const pct = (v / total) * 100;
return label + ' - ' + (pct < 10 ? pct.toFixed(1) : String(Math.round(pct))) + '%';
})
: labels;
#if($layoutType == "REPORT_POLARAREA")
return { labels: sliceLabels, series, legend: true };
#else
// dataLabels off: the chart library prints the raw value inside a pie/doughnut slice with a
// percent sign, so a 336-hour sum reads "336%" (codbex/harmonia#113, fixed after 2.14.2). The
// share is in the legend instead.
return { labels: sliceLabels, series, legend: true, dataLabels: false };
#end
#else
return { labels, series };
#end
},

// Print the chart: rasterize the rendered SVG to a PNG (Harmonia.chartToImage) into a minimal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,10 +261,13 @@ class IntentEngineIT extends IntegrationTest {
actions: [approve, reject]

reports:
# chart: doughnut renders the aggregated rows as a circular chart beside the table -
# its slices ARE the dimension values, so the page names them in the legend.
- name: OrdersByCustomer
source: Order
dimensions: [customer]
measures: ["count(*)", "sum(total)"]
chart: doughnut
# month(field) buckets a date dimension into a sortable YYYYMM integer. The widget
# turns the report into a dashboard KPI: one aggregate cell, the month pinned to now.
- name: OrdersByMonth
Expand Down Expand Up @@ -3605,6 +3608,16 @@ void report_file_stack_generates_typed_column_filters() {
assertTrue(page.contains("pattern: '### ### ### ##0.00'"), "the page metadata should carry the money pattern for decimal columns");
assertTrue(page.contains("limit: 20"), "an ordinary report should page in twenties");

// A circular chart is sliced by the dimension, so its values are named by the legend alone -
// on whatever the series count - and each legend entry carries the slice's share of the
// measure total. The in-slice data label stays off for pie/doughnut: the chart library prints
// the raw measure there with a percent sign, which turned a 336-hour sum into "336%".
assertTrue(page.contains("chartType: 'doughnut'"), "the chart report page should carry its chart type");
assertTrue(page.contains("legend: true") && page.contains("dataLabels: false"),
"a doughnut should keep its legend and drop the raw-value-as-percentage slice label");
assertTrue(page.contains("shareLabels(labels, data[0])"), "doughnut legend entries should carry the slice share");
assertTrue(page.contains("shell.report.true"), "a boolean dimension should read Yes/No in the chart, not true/false");

// A statement's rows ARE its structure, so its page fetches the whole statement rather than
// splitting a balance sheet across pages.
restAssuredExecutor.execute(() -> given().contentType("application/json")
Expand Down
Loading