From 35bc5561cbf11cc80f4fd6033d4737a6620a1c5f Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 14 Jan 2026 19:28:05 -0300 Subject: [PATCH 01/21] Add Luxon, chartjs-adapter-luxon dependencies --- frontend/package-lock.json | 31 +++++++++++++++++++++++++++++++ frontend/package.json | 2 ++ 2 files changed, 33 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fde984b292b..8f569a7ca87 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -75,6 +75,7 @@ "autoprefixer": "^10.4.23", "byte-base64": "^1.1.0", "chart.js": "4.5.1", + "chartjs-adapter-luxon": "^1.3.1", "chartjs-plugin-datalabels": "^2.2.0", "codemirror": "^5.62.0", "copy-text-to-clipboard": "^3.2.2", @@ -95,6 +96,7 @@ "json5": "^2.2.2", "lit-html": "^3.3.2", "lodash": "^4.17.23", + "luxon": "^3.7.2", "mark.js": "^8.11.0", "mdx-embed": "^1.1.2", "mime": "^4.1.0", @@ -12106,6 +12108,16 @@ "pnpm": ">=8" } }, + "node_modules/chartjs-adapter-luxon": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/chartjs-adapter-luxon/-/chartjs-adapter-luxon-1.3.1.tgz", + "integrity": "sha512-yxHov3X8y+reIibl1o+j18xzrcdddCLqsXhriV2+aQ4hCR66IYFchlRXUvrJVoxglJ380pgytU7YWtoqdIgqhg==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.0.0", + "luxon": ">=1.0.0" + } + }, "node_modules/chartjs-plugin-datalabels": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", @@ -18027,6 +18039,15 @@ "es5-ext": "~0.10.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -33160,6 +33181,11 @@ "@kurkle/color": "^0.3.0" } }, + "chartjs-adapter-luxon": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/chartjs-adapter-luxon/-/chartjs-adapter-luxon-1.3.1.tgz", + "integrity": "sha512-yxHov3X8y+reIibl1o+j18xzrcdddCLqsXhriV2+aQ4hCR66IYFchlRXUvrJVoxglJ380pgytU7YWtoqdIgqhg==" + }, "chartjs-plugin-datalabels": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", @@ -37343,6 +37369,11 @@ "es5-ext": "~0.10.2" } }, + "luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==" + }, "magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", diff --git a/frontend/package.json b/frontend/package.json index 016ac7c353c..414ac60b492 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -130,6 +130,7 @@ "autoprefixer": "^10.4.23", "byte-base64": "^1.1.0", "chart.js": "4.5.1", + "chartjs-adapter-luxon": "^1.3.1", "chartjs-plugin-datalabels": "^2.2.0", "codemirror": "^5.62.0", "copy-text-to-clipboard": "^3.2.2", @@ -150,6 +151,7 @@ "json5": "^2.2.2", "lit-html": "^3.3.2", "lodash": "^4.17.23", + "luxon": "^3.7.2", "mark.js": "^8.11.0", "mdx-embed": "^1.1.2", "mime": "^4.1.0", From 131105820a2dd3e465b0af6335c6ada13a6b4d7c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 7 Nov 2025 12:29:20 +0000 Subject: [PATCH 02/21] [#66124] Introduce Budget widgets on Overview page Adds three budget/cost visualization widgets to the project Overview page: - `BudgetTotals`: displays total actual costs, planned budget, spent percentage, and remaining budget - `BudgetByCostType`: pie chart breakdown by labor and material cost types - `ActualCosts`: monthly spending trends as a bar chart Introduces aggregation models (`Budgets::AggregatedBudgets`, `Budgets::AggregatedBudgetsWithSpend`, `Costs::AggregatedCosts`) to compute budget and spend totals across project hierarchies using the `ProjectAggregation` concern. Frontend components use Angular + Chart.js with shared tooltip configuration in `chart.config.ts`. Updates `.widget-box` styles to use a standard Primer gap instead of margins. https://community.openproject.org/wp/66124 Co-Authored-By: Claude Sonnet 4.5 --- frontend/src/app/app.module.ts | 4 + .../components/budget-graphs/chart.config.ts | 80 ++ .../overview/actual-costs.component.html | 3 + .../overview/actual-costs.component.ts | 104 +++ .../budget-by-cost-type.component.html | 3 + .../overview/budget-by-cost-type.component.ts | 78 ++ .../global_styles/content/_widget_box.sass | 9 +- .../global_styles/openproject/_mixins.sass | 1 - .../components/budgets/widget_component.rb | 45 ++ .../widgets/budget_by_cost_type.html.erb | 78 ++ .../budgets/widgets/budget_by_cost_type.rb | 65 ++ .../budgets/widgets/budget_totals.html.erb | 50 ++ .../budgets/widgets/budget_totals.rb | 85 ++ .../app/models/budgets/aggregated_budgets.rb | 100 +++ .../budgets/aggregated_budgets_with_spend.rb | 67 ++ .../app/models/budgets/project_aggregation.rb | 52 ++ modules/budgets/config/locales/en.yml | 33 + .../widgets/budget_by_cost_type_spec.rb | 159 ++++ .../budgets/widgets/budget_totals_spec.rb | 153 ++++ .../models/budgets/aggregated_budgets_spec.rb | 170 ++++ .../aggregated_budgets_with_spend_spec.rb | 292 +++++++ .../app/components/costs/widget_component.rb | 45 ++ .../costs/widgets/actual_costs.html.erb | 61 ++ .../components/costs/widgets/actual_costs.rb | 83 ++ modules/costs/app/models/cost_entry.rb | 5 + .../app/models/costs/aggregated_costs.rb | 103 +++ modules/costs/app/models/time_entry.rb | 4 + modules/costs/config/locales/en.yml | 9 + .../costs/widgets/actual_costs_spec.rb | 196 +++++ .../models/costs/aggregated_costs_spec.rb | 762 ++++++++++++++++++ .../components/grids/widget_box_component.rb | 6 +- ...portfolio_overview_grid_component.html.erb | 3 + .../program_overview_grid_component.html.erb | 3 + .../project_overview_grid_component.html.erb | 3 + 34 files changed, 2909 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/shared/components/budget-graphs/chart.config.ts create mode 100644 frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html create mode 100644 frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts create mode 100644 frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html create mode 100644 frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts create mode 100644 modules/budgets/app/components/budgets/widget_component.rb create mode 100644 modules/budgets/app/components/budgets/widgets/budget_by_cost_type.html.erb create mode 100644 modules/budgets/app/components/budgets/widgets/budget_by_cost_type.rb create mode 100644 modules/budgets/app/components/budgets/widgets/budget_totals.html.erb create mode 100644 modules/budgets/app/components/budgets/widgets/budget_totals.rb create mode 100644 modules/budgets/app/models/budgets/aggregated_budgets.rb create mode 100644 modules/budgets/app/models/budgets/aggregated_budgets_with_spend.rb create mode 100644 modules/budgets/app/models/budgets/project_aggregation.rb create mode 100644 modules/budgets/spec/components/budgets/widgets/budget_by_cost_type_spec.rb create mode 100644 modules/budgets/spec/components/budgets/widgets/budget_totals_spec.rb create mode 100644 modules/budgets/spec/models/budgets/aggregated_budgets_spec.rb create mode 100644 modules/budgets/spec/models/budgets/aggregated_budgets_with_spend_spec.rb create mode 100644 modules/costs/app/components/costs/widget_component.rb create mode 100644 modules/costs/app/components/costs/widgets/actual_costs.html.erb create mode 100644 modules/costs/app/components/costs/widgets/actual_costs.rb create mode 100644 modules/costs/app/models/costs/aggregated_costs.rb create mode 100644 modules/costs/spec/components/costs/widgets/actual_costs_spec.rb create mode 100644 modules/costs/spec/models/costs/aggregated_costs_spec.rb diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 802e6b3ec6e..d3edb6674eb 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -207,6 +207,8 @@ import { WorkPackageFullViewEntryComponent } from 'core-app/features/work-packag import { MyPageComponent } from './features/my-page/my-page.component'; import { DashboardComponent } from './features/overview/dashboard.component'; import { BurndownChartComponent } from './features/backlogs/burndown-chart.component'; +import { BudgetByCostTypeComponent } from './shared/components/budget-graphs/overview/budget-by-cost-type.component'; +import { ActualCostsComponent } from './shared/components/budget-graphs/overview/actual-costs.component'; export function initializeServices(injector:Injector) { return () => { @@ -421,5 +423,7 @@ export class OpenProjectModule implements DoBootstrap { registerCustomElement('opce-my-page', MyPageComponent, { injector }); registerCustomElement('opce-dashboard', DashboardComponent, { injector }); registerCustomElement('opce-burndown-chart', BurndownChartComponent, { injector }); + registerCustomElement('opce-budget-by-cost-type', BudgetByCostTypeComponent, { injector }); + registerCustomElement('opce-actual-costs', ActualCostsComponent, { injector }); } } diff --git a/frontend/src/app/shared/components/budget-graphs/chart.config.ts b/frontend/src/app/shared/components/budget-graphs/chart.config.ts new file mode 100644 index 00000000000..bc119656fde --- /dev/null +++ b/frontend/src/app/shared/components/budget-graphs/chart.config.ts @@ -0,0 +1,80 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { ChartOptions, TooltipModel } from 'chart.js'; +import { html, render } from 'lit-html'; + +export const chartFont:ChartOptions['font'] = { + family: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'", + size: 14, +}; + +export const chartLegend:ChartOptions['plugins'] = { + legend: { + position: 'bottom', + labels: { + boxWidth: 56, + boxHeight: 20, + padding: 16, + font: { size: 14 }, + }, + }, +}; + +export function renderChartTooltip(context:{ chart:{ canvas:HTMLCanvasElement }, tooltip:TooltipModel }) { + const tooltipModel = context.tooltip; + const popoverHtml = html` +
+
+ ${tooltipModel.title} +
    + ${tooltipModel.body.map((item) => item.lines).map((body) => { + return html`
  • ${body}
  • `; + })} +
+
+
`; + + render(popoverHtml, document.body); + + const tooltipEl = document.getElementById('chartjs-tooltip')!; + + if (tooltipModel.opacity === 0) { + tooltipEl.style.opacity = '0'; + return; + } + + const position = context.chart.canvas.getBoundingClientRect(); + + tooltipEl.style.opacity = '1'; + tooltipEl.style.position = 'absolute'; + tooltipEl.style.left = position.left + window.pageXOffset + tooltipModel.caretX + 'px'; + tooltipEl.style.top = position.top + window.pageYOffset + tooltipModel.caretY + 'px'; + tooltipEl.style.pointerEvents = 'none'; +} diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html new file mode 100644 index 00000000000..51b07b8d23f --- /dev/null +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts new file mode 100644 index 00000000000..cc85ecea391 --- /dev/null +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts @@ -0,0 +1,104 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { ChartConfiguration, ChartData } from 'chart.js'; +import 'chartjs-adapter-luxon'; +import { chartFont, chartLegend, renderChartTooltip } from 'core-app/shared/components/budget-graphs/chart.config'; +import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; +import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; + +@Component({ + selector: 'opce-actual-costs', + templateUrl: './actual-costs.component.html', + imports: [BaseChartDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [provideCharts(withDefaultRegisterables(PrimerColorsPlugin))], +}) +export class ActualCostsComponent { + readonly chartData = input.required(); + readonly currency = input('EUR'); + + readonly barChartData = computed>(() => JSON.parse(this.chartData()) as ChartData<'bar'>); + + readonly barChartOptions = computed['options']>(() => ({ + font: chartFont, + scales: { + x: { + stacked: true, + type: 'time', + time: { + unit: 'month', + }, + }, + y: { + stacked: true, + ticks: { + callback: (value) => this.formatCurrencyCompact(value as number), + }, + }, + }, + plugins: { + ...chartLegend, + tooltip: { + enabled: false, + external: renderChartTooltip, + callbacks: { + label: (context) => { + const label = context.dataset.label ?? ''; + const value = context.raw as number; + return `${label}: ${this.formatCurrency(value)}`; + }, + }, + }, + }, + })); + + private formatCurrencyCompact(value:number):string { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: this.currency(), + notation: 'compact', + compactDisplay: 'short', + maximumFractionDigits: 1, + }).format(value); + } + + private formatCurrency(value:number):string { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: this.currency(), + maximumFractionDigits: 0, + }).format(value); + } +} diff --git a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html new file mode 100644 index 00000000000..a6f02766f01 --- /dev/null +++ b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts new file mode 100644 index 00000000000..fd705283ae4 --- /dev/null +++ b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts @@ -0,0 +1,78 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { ChartConfiguration, ChartData } from 'chart.js'; +import { chartFont, chartLegend, renderChartTooltip } from 'core-app/shared/components/budget-graphs/chart.config'; +import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; +import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; + +@Component({ + selector: 'opce-budget-by-cost-type', + templateUrl: './budget-by-cost-type.component.html', + imports: [BaseChartDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [provideCharts(withDefaultRegisterables(PrimerColorsPlugin))], +}) +export class BudgetByCostTypeComponent { + readonly chartData = input.required(); + readonly currency = input('EUR'); + + readonly pieChartData = computed>(() => JSON.parse(this.chartData()) as ChartData<'pie'>); + + readonly pieChartOptions = computed['options']>(() => ({ + font: chartFont, + plugins: { + ...chartLegend, + tooltip: { + enabled: false, + external: renderChartTooltip, + callbacks: { + label: (context) => { + const label = context.label ?? ''; + const value = context.parsed; + return `${label}: ${this.formatCurrency(value)}`; + }, + }, + }, + }, + })); + + private formatCurrency(value:number):string { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: this.currency(), + maximumFractionDigits: 0, + }).format(value); + } +} diff --git a/frontend/src/global_styles/content/_widget_box.sass b/frontend/src/global_styles/content/_widget_box.sass index a0200155831..7e848e6e1dc 100644 --- a/frontend/src/global_styles/content/_widget_box.sass +++ b/frontend/src/global_styles/content/_widget_box.sass @@ -33,16 +33,18 @@ $widget-box--enumeration-width: 20px &.-flex display: flex flex-flow: row wrap - // cancel the margin of the outer elements to align with the rest of the page - margin: 0 -10px + gap: var(--stack-gap-normal) .widget-box flex: 1 - flex-basis: 32% + flex-basis: calc(50% - var(--stack-gap-normal)) max-height: 750px // Avoid that individual widgets blow the whole grid display: flex flex-direction: column + &_half-width + flex-basis: calc(25% - var(--stack-gap-normal)) + &_full-width flex-basis: 100% @@ -77,6 +79,7 @@ $widget-box--enumeration-width: 20px &.-no-border padding: 0 border: 0 + box-shadow: none &--teaser-image width: 200px diff --git a/frontend/src/global_styles/openproject/_mixins.sass b/frontend/src/global_styles/openproject/_mixins.sass index f2f7df40760..1e90efcd307 100644 --- a/frontend/src/global_styles/openproject/_mixins.sass +++ b/frontend/src/global_styles/openproject/_mixins.sass @@ -82,7 +82,6 @@ @mixin widget-box--style background: var(--body-background) - margin: 10px border: var(--borderWidth-thin) solid var(--borderColor-default) box-shadow: var(--shadow-resting-small) border-radius: var(--borderRadius-medium) diff --git a/modules/budgets/app/components/budgets/widget_component.rb b/modules/budgets/app/components/budgets/widget_component.rb new file mode 100644 index 00000000000..ad8effa96dc --- /dev/null +++ b/modules/budgets/app/components/budgets/widget_component.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Budgets + class WidgetComponent < Grids::WidgetComponent + param :project + + def render? + project.module_enabled?(:budgets) && has_required_permissions? + end + + private + + def has_required_permissions? + true + end + end +end diff --git a/modules/budgets/app/components/budgets/widgets/budget_by_cost_type.html.erb b/modules/budgets/app/components/budgets/widgets/budget_by_cost_type.html.erb new file mode 100644 index 00000000000..fcf012c7353 --- /dev/null +++ b/modules/budgets/app/components/budgets/widgets/budget_by_cost_type.html.erb @@ -0,0 +1,78 @@ +<%# -- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++# %> + +<%= + widget_wrapper do + if has_budgets? + flex_layout do |flex| + flex.with_row do + render(Primer::Beta::Text.new(color: :subtle, font_size: :small)) do + if project.portfolio? + t( + ".caption", + count: budget_count, + portfolios: t(".portfolio", count: workspace_counts[:portfolio] || 0), + subprograms: t(".subprogram", count: workspace_counts[:program] || 0), + subprojects: t(".subproject", count: workspace_counts[:project] || 0) + ) + else + t(".caption_simple", count: budget_count) + end + end + end + flex.with_row(mt: 3, align: :center) do + helpers.angular_component_tag( + "opce-budget-by-cost-type", + currency: Setting.costs_currency, + "chart-data": { + labels: chart_labels, + datasets: [{ + label: t(:label_budget), + data: chart_data + }] + }.to_json + ) + end + end + else + render(Primer::Beta::Blankslate.new) do |blankslate| + blankslate.with_visual_icon(icon: :"op-budget") + blankslate.with_heading(tag: :h3).with_content(t(".blankslate.heading")) + blankslate.with_description_content(t(".blankslate.description")) + blankslate.with_primary_action( + href: helpers.new_projects_budget_path(project), + scheme: :secondary + ) do |button| + button.with_leading_visual_icon(icon: :plus) + Budget.human_model_name + end + end + end + end +%> diff --git a/modules/budgets/app/components/budgets/widgets/budget_by_cost_type.rb b/modules/budgets/app/components/budgets/widgets/budget_by_cost_type.rb new file mode 100644 index 00000000000..03be70936f5 --- /dev/null +++ b/modules/budgets/app/components/budgets/widgets/budget_by_cost_type.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Budgets + module Widgets + class BudgetByCostType < Budgets::WidgetComponent + REQUIRED_PERMISSIONS = %i[view_budgets view_cost_rates].freeze + + delegate :budget_count, :has_budgets?, :budgeted_labor, :budgeted_material_by_type, + :workspace_counts, + to: :@aggregated_budgets + + def initialize(...) + super + + @aggregated_budgets = Budgets::AggregatedBudgets.new(project:, current_user:) + end + + def title + t(".title") + end + + def chart_labels + [t(:caption_labor)] + budgeted_material_by_type.keys + end + + def chart_data + [budgeted_labor] + budgeted_material_by_type.values.map(&:to_f) + end + + private + + def has_required_permissions? + REQUIRED_PERMISSIONS.all? { |perm| current_user.allowed_in_project?(perm, project) } + end + end + end +end diff --git a/modules/budgets/app/components/budgets/widgets/budget_totals.html.erb b/modules/budgets/app/components/budgets/widgets/budget_totals.html.erb new file mode 100644 index 00000000000..32734a6cf3f --- /dev/null +++ b/modules/budgets/app/components/budgets/widgets/budget_totals.html.erb @@ -0,0 +1,50 @@ +<%# -- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++# %> + +<%= + widget_wrapper do + render(Grids::WidgetGridComponent.new(aria: { label: t(".title") })) do |grid| + grid.with_widget(InlineWidget, key: :total_actual_costs, title: t(".total_actual_costs")) do + render_currency(spent_total) + end + + grid.with_widget(InlineWidget, key: :total_planned_budget, title: t(".total_planned_budget")) do + render_currency(budgeted_total) + end + + grid.with_widget(InlineWidget, key: :spent_budget, title: t(".spent_budget")) do + render_percentage(spent_ratio * 100) + end + + grid.with_widget(InlineWidget, key: :remaining_budget, title: t(".remaining_budget")) do + render_currency(remaining) + end + end + end +%> diff --git a/modules/budgets/app/components/budgets/widgets/budget_totals.rb b/modules/budgets/app/components/budgets/widgets/budget_totals.rb new file mode 100644 index 00000000000..79c0896726e --- /dev/null +++ b/modules/budgets/app/components/budgets/widgets/budget_totals.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Budgets + module Widgets + class BudgetTotals < Budgets::WidgetComponent + delegate :has_budgets?, :budgeted_total, :spent_total, :spent_ratio, :remaining, + to: :@aggregated_budgets_with_spend + + def initialize(...) + super + + @aggregated_budgets_with_spend = Budgets::AggregatedBudgetsWithSpend.new(project:, current_user:) + end + + class InlineWidget < Grids::WidgetComponent + option :key, as: :wrapper_key + option :title + + def call + widget_wrapper { content } + end + + def wrapper_arguments + { turbo_enabled: false, half_width: true, role: "region", aria: { labelledby: "#{wrapper_key}-header" } } + end + end + + def title + nil + end + + def wrapper_arguments + { content_padding: :none, full_width: true, border: false } + end + + def render? + super && project.module_enabled?(:costs) && has_budgets? + end + + private + + def render_currency(value) + color = value.negative? ? :danger : :default + render(Primer::Beta::Truncate.new(font_weight: :bold, font_size: 1, color:)) do + number_to_currency(value, precision: 0) + end + end + + def render_percentage(value) + color = value > 100 ? :danger : :default + render(Primer::Beta::Truncate.new(font_weight: :bold, font_size: 1, color:)) do + number_to_percentage(value, precision: 2) + end + end + end + end +end diff --git a/modules/budgets/app/models/budgets/aggregated_budgets.rb b/modules/budgets/app/models/budgets/aggregated_budgets.rb new file mode 100644 index 00000000000..853628a5e7a --- /dev/null +++ b/modules/budgets/app/models/budgets/aggregated_budgets.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Budgets + class AggregatedBudgets + include Budgets::ProjectAggregation + + def initialize(project:, current_user: User.current) + @project = project + @current_user = current_user + end + + def budget_count + budgets.count + end + + def budgeted_base + budgets.sum(:base_amount) + end + + def budgeted_material + material_budget_items.sum(:amount) + end + + def budgeted_material_by_type + material_budget_items_by_type.sum("material_budget_items.amount") + end + + def budgeted_labor + labor_budget_items.sum(:amount) + end + + def budgeted_total + budgeted_base + budgeted_material + budgeted_labor + end + + def has_budgets? + budget_count.positive? + end + + private + + def budgets + Budget + .joins(:project) + .merge(applicable_projects) + .visible(current_user) + end + + def material_budget_items + MaterialBudgetItem + .joins(budget: :project) + .merge(applicable_projects) + .visible(current_user) + end + + def material_budget_items_by_type + CostType + .left_joins(material_budget_items: { budget: :project }) + .merge(applicable_projects) + .merge(MaterialBudgetItem.visible(current_user)) + .group(:name) + .order(name: :asc) + end + + def labor_budget_items + LaborBudgetItem + .joins(budget: :project) + .merge(applicable_projects) + .visible(current_user) + end + end +end diff --git a/modules/budgets/app/models/budgets/aggregated_budgets_with_spend.rb b/modules/budgets/app/models/budgets/aggregated_budgets_with_spend.rb new file mode 100644 index 00000000000..0aac7b28b0b --- /dev/null +++ b/modules/budgets/app/models/budgets/aggregated_budgets_with_spend.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Budgets + class AggregatedBudgetsWithSpend + attr_reader :project, :current_user + + def initialize(project:, current_user: User.current) + @project = project + @current_user = current_user + end + + # Delegate to aggregated_budgets + delegate :budget_count, :has_budgets?, + :budgeted_base, :budgeted_material, :budgeted_labor, :budgeted_total, + to: :aggregated_budgets + + # Delegate to aggregated_costs + delegate :spent_material, :spent_labor, :spent_total, + to: :aggregated_costs + + def spent_ratio + @spent_ratio ||= budgeted_total.zero? ? BigDecimal("0") : spent_total / budgeted_total + end + + def remaining + @remaining ||= budgeted_total - spent_total + end + + private + + def aggregated_budgets + @aggregated_budgets ||= Budgets::AggregatedBudgets.new(project:, current_user:) + end + + def aggregated_costs + @aggregated_costs ||= Costs::AggregatedCosts.new(project:, current_user:) + end + end +end diff --git a/modules/budgets/app/models/budgets/project_aggregation.rb b/modules/budgets/app/models/budgets/project_aggregation.rb new file mode 100644 index 00000000000..ff103ef0805 --- /dev/null +++ b/modules/budgets/app/models/budgets/project_aggregation.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Budgets::ProjectAggregation + extend ActiveSupport::Concern + + included do + attr_reader :project, :current_user + end + + def workspace_counts + project.descendants.reorder(nil) + .group(:workspace_type) + .count + .with_indifferent_access + end + + private + + def applicable_projects + return Project.where(id: project.id) unless project.portfolio? + + project.self_and_descendants.reorder(nil) + end +end diff --git a/modules/budgets/config/locales/en.yml b/modules/budgets/config/locales/en.yml index 02305b16ccd..d24d6264ae0 100644 --- a/modules/budgets/config/locales/en.yml +++ b/modules/budgets/config/locales/en.yml @@ -70,6 +70,39 @@ en: caption_material_costs: "Actual unit costs" budgets_title: "Budgets" + budgets: + widgets: + budget_totals: + title: "Budget totals" + remaining_budget: "Remaining budget" + spent_budget: "Spent budget" + total_actual_costs: "Total actual costs" + total_planned_budget: "Total planned budget" + budget_by_cost_type: + title: "Budget by cost type" + blankslate: + heading: "Start project controlling" + description: "Get an overview of your budgets and costs to efficiently track the health status of your project" + caption: + zero: "No budget data." + one: "Data aggregated from %{count} budget included in %{portfolios}, %{subprograms} and %{subprojects}." + other: "Data aggregated from %{count} budgets included in %{portfolios}, %{subprograms} and %{subprojects}." + caption_simple: + one: "Data aggregated from %{count} budget." + other: "Data aggregated from %{count} budgets." + portfolio: + zero: "no portfolios" + one: "1 portfolio" + other: "%{count} portfolios" + subprogram: + zero: "no subprograms" + one: "1 subprogram" + other: "%{count} subprograms" + subproject: + zero: "no subprojects" + one: "1 subproject" + other: "%{count} subprojects" + events: budget: "Budget edited" diff --git a/modules/budgets/spec/components/budgets/widgets/budget_by_cost_type_spec.rb b/modules/budgets/spec/components/budgets/widgets/budget_by_cost_type_spec.rb new file mode 100644 index 00000000000..da8271a1473 --- /dev/null +++ b/modules/budgets/spec/components/budgets/widgets/budget_by_cost_type_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Budgets::Widgets::BudgetByCostType, type: :component do + def render_component(...) + render_inline(described_class.new(...)) + end + + let(:project) { create(:project_with_types) } + let(:current_user) do + create(:user, member_with_permissions: { project => %i[view_budgets view_cost_rates] }) + end + + subject(:rendered_component) { render_component(project, current_user:) } + + context "with budget and items" do + let(:cost_type) { create(:cost_type, name: "Materials A") } + let!(:budget) { create(:budget, project: project) } + let!(:labor_item) do + create(:labor_budget_item, + budget: budget, + user: current_user, + hours: 100, + amount: BigDecimal("5000")) + end + let!(:material_item) do + create(:material_budget_item, + budget: budget, + cost_type: cost_type, + units: 50, + amount: BigDecimal("3000")) + end + + it "renders angular component" do + expect(rendered_component).to have_css("opce-budget-by-cost-type") + end + + it "displays simple caption for non-portfolio project" do + expect(rendered_component).to have_text(/Data aggregated from 1 budget\./) + expect(rendered_component).to have_no_text(/portfolios/) + end + + it "passes currency attribute" do + expect(rendered_component).to have_element "opce-budget-by-cost-type" do |element| + expect(element["currency"]).to eq(Setting.costs_currency) + end + end + + it "passes chart data with correct structure" do + expect(rendered_component).to have_element "opce-budget-by-cost-type" do |element| + chart_data_json = element["chart-data"] + expect(chart_data_json).to be_present + + chart_data = JSON.parse(chart_data_json) + expect(chart_data).to have_key("labels") + expect(chart_data).to have_key("datasets") + expect(chart_data["labels"]).to be_an(Array) + expect(chart_data["datasets"]).to be_an(Array) + expect(chart_data["datasets"]).to have_attributes(size: 1) + + dataset = chart_data["datasets"].first + expect(dataset).to have_key("label") + expect(dataset).to have_key("data") + expect(dataset["label"]).to eq(I18n.t(:label_budget)) + expect(dataset["data"]).to be_an(Array) + end + end + + it "includes budget data with labor and material costs" do + expect(rendered_component).to have_element "opce-budget-by-cost-type" do |element| + chart_data = JSON.parse(element["chart-data"]) + + # Should have labels for labor and material type + expect(chart_data["labels"]).to include(I18n.t(:caption_labor)) + expect(chart_data["labels"]).to include("Materials A") + + # Should have corresponding data values + expect(chart_data["datasets"].first["data"].size).to eq(chart_data["labels"].size) + # Data values should be numeric (convert strings to float for comparison) + data_values = chart_data["datasets"].first["data"].map(&:to_f) + expect(data_values).to all(be_a(Numeric)) + end + end + end + + context "with a portfolio project" do + let(:project) { create(:portfolio) } + let!(:budget) { create(:budget, project:) } + + it "displays full caption with portfolio detail" do + expect(rendered_component).to have_text(/Data aggregated from 1 budget included in/) + expect(rendered_component).to have_text(/portfolios/) + expect(rendered_component).to have_text(/subprograms/) + expect(rendered_component).to have_text(/subprojects/) + end + end + + context "without budgets" do + it_behaves_like "rendering Blank Slate", + heading: I18n.t("budgets.widgets.budget_by_cost_type.blankslate.heading") + end + + context "without proper permissions" do + let(:current_user) do + create(:user, member_with_permissions: { project => %i[view_work_packages] }) + end + + it "renders nothing" do + expect(rendered_component.to_s).to be_empty + end + end + + context "without view_cost_rates permission" do + let(:current_user) do + create(:user, member_with_permissions: { project => %i[view_budgets] }) + end + + it "renders nothing when missing cost rate permissions" do + expect(rendered_component.to_s).to be_empty + end + end + + describe "#wrapper_arguments" do + let(:component) { described_class.new(project) } + + it "returns empty hash" do + expect(component.wrapper_arguments).to eq({}) + end + end +end diff --git a/modules/budgets/spec/components/budgets/widgets/budget_totals_spec.rb b/modules/budgets/spec/components/budgets/widgets/budget_totals_spec.rb new file mode 100644 index 00000000000..a66c1e7dfb8 --- /dev/null +++ b/modules/budgets/spec/components/budgets/widgets/budget_totals_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Budgets::Widgets::BudgetTotals, type: :component do + include Rails.application.routes.url_helpers + + def render_component(...) + render_inline(described_class.new(...)) + end + + let(:project) { create(:project_with_types) } + let(:current_user) do + create(:user, + member_with_permissions: { project => %i[view_budgets + view_cost_entries + view_cost_rates + view_time_entries + view_hourly_rates] }) + end + + subject(:rendered_component) { render_component(project, current_user:) } + + context "with budget data but no spending" do + let!(:budget) { create(:budget, project:, base_amount: BigDecimal("10000")) } + + it "displays budget total" do + expect(rendered_component).to have_heading("Total planned budget") + expect(rendered_component).to have_primer_text("10,000 EUR") + end + + it "displays spent ratio as percentage" do + expect(rendered_component).to have_heading("Spent budget") + expect(rendered_component).to have_primer_text("0.00%", color: "default") + end + + it "displays remaining budget equal to planned budget" do + expect(rendered_component).to have_heading("Remaining budget") + expect(rendered_component).to have_primer_text("10,000 EUR", color: "default") + end + + it "displays zero actual costs" do + expect(rendered_component).to have_heading("Total actual costs") + expect(rendered_component).to have_primer_text("0 EUR") + end + end + + context "with budget and spending data" do + let(:work_package) { create(:work_package, project:) } + let!(:budget) { create(:budget, project:, base_amount: BigDecimal("10000")) } + let!(:hourly_rate) do + create(:hourly_rate, user: current_user, project:, rate: 50.0, valid_from: 1.month.ago) + end + let!(:time_entry) do + create(:time_entry, entity: work_package, project:, user: current_user, hours: 40, spent_on: Date.current) + end + + it "displays actual costs based on time entries" do + expect(rendered_component).to have_heading("Total actual costs") + expect(rendered_component).to have_primer_text("2,000 EUR") + end + + it "displays remaining budget reduced by spending" do + expect(rendered_component).to have_heading("Remaining budget") + expect(rendered_component).to have_primer_text("8,000 EUR", color: "default") + end + + it "displays spent ratio as percentage" do + expect(rendered_component).to have_heading("Spent budget") + expect(rendered_component).to have_primer_text("20.00%", color: "default") + end + end + + context "with overspending (negative remaining)" do + let(:work_package) { create(:work_package, project:) } + let!(:budget) { create(:budget, project:, base_amount: BigDecimal("5000")) } + let!(:hourly_rate) do + create(:hourly_rate, user: current_user, project:, rate: 100.0, valid_from: 1.month.ago) + end + let!(:time_entry) do + create(:time_entry, entity: work_package, project:, user: current_user, hours: 100, spent_on: Date.current) + end + + it "displays negative remaining budget in red" do + expect(rendered_component).to have_heading("Remaining budget") + expect(rendered_component).to have_primer_text("-EUR5,000", color: "danger") + end + + it "displays over-100% spent ratio in red" do + expect(rendered_component).to have_heading("Spent budget") + expect(rendered_component).to have_primer_text("200.00%", color: "danger") + end + + it "displays actual costs exceeding budget" do + expect(rendered_component).to have_heading("Total actual costs") + expect(rendered_component).to have_primer_text("10,000 EUR") + end + end + + context "without proper permissions" do + let(:current_user) do + create(:user, member_with_permissions: { project => %i[view_work_packages] }) + end + + it "renders nothing" do + expect(rendered_component.to_s).to be_empty + end + end + + describe "#title" do + let(:component) { described_class.new(project) } + + it "returns nil" do + expect(component.title).to be_nil + end + end + + describe "#wrapper_arguments" do + let(:component) { described_class.new(project) } + + it "returns border: false, content_padding: :none and full_width: true" do + expect(component.wrapper_arguments).to eq({ border: false, content_padding: :none, full_width: true }) + end + end +end diff --git a/modules/budgets/spec/models/budgets/aggregated_budgets_spec.rb b/modules/budgets/spec/models/budgets/aggregated_budgets_spec.rb new file mode 100644 index 00000000000..7295a19e78c --- /dev/null +++ b/modules/budgets/spec/models/budgets/aggregated_budgets_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Budgets::AggregatedBudgets do + shared_let(:project) { create(:project_with_types) } + shared_let(:user) do + create(:user, member_with_permissions: { project => %i[view_budgets] }) + end + + subject(:aggregated) { described_class.new(project:, current_user: user) } + + describe "#budget_count" do + context "with no budgets" do + it "returns 0" do + expect(aggregated.budget_count).to eq(0) + end + end + + context "with visible budgets" do + let!(:budget1) { create(:budget, project:) } + let!(:budget2) { create(:budget, project:) } + + it "counts all visible budgets" do + expect(aggregated.budget_count).to eq(2) + end + end + + context "without view_budgets permission" do + let(:user_without_permissions) { create(:user) } + let(:aggregated_for_restricted_user) { described_class.new(project:, current_user: user_without_permissions) } + let!(:budget) { create(:budget, project:) } + + it "returns 0" do + expect(aggregated_for_restricted_user.budget_count).to eq(0) + end + end + + context "with budgets in other projects" do + let!(:other_project) { create(:project_with_types) } + let!(:budget_in_project) { create(:budget, project:) } + let!(:budget_in_other_project) { create(:budget, project: other_project) } + + it "counts only budgets in the specified project" do + expect(aggregated.budget_count).to eq(1) + end + end + end + + describe "#budgeted_total" do + context "with no budgets" do + it "returns 0" do + expect(aggregated.budgeted_total).to eq(0) + end + end + + context "with base_amount only" do + let!(:budget) { create(:budget, project:, base_amount: BigDecimal("10000")) } + + it "returns the base amount" do + expect(aggregated.budgeted_total).to eq(BigDecimal("10000")) + end + end + + context "with all components" do + let(:user_with_rates) do + create(:user, + member_with_permissions: { project => %i[view_budgets view_cost_rates view_hourly_rates] }) + end + let(:aggregated) { described_class.new(project:, current_user: user_with_rates) } + let!(:cost_type) { create(:cost_type) } + let!(:budget) { create(:budget, project:, base_amount: BigDecimal("10000")) } + let!(:labor_item) do + create(:labor_budget_item, + budget:, + user: user_with_rates, + hours: 100, + amount: BigDecimal("5000")) + end + let!(:material_item) do + create(:material_budget_item, + budget:, + cost_type:, + units: 50, + amount: BigDecimal("3000")) + end + + it "sums base_amount, labor, and material amounts" do + expect(aggregated.budgeted_total).to eq(BigDecimal("18000")) + end + end + end + + describe "portfolio project support" do + let(:portfolio) do + create(:portfolio).tap do |p| + p.enabled_module_names += %w[budgets] + p.save! + end + end + let(:child_project_one) do + create(:project_with_types, parent: portfolio).tap do |p| + p.enabled_module_names += %w[budgets] + p.save! + end + end + let(:child_project_two) do + create(:project_with_types, parent: portfolio).tap do |p| + p.enabled_module_names += %w[budgets] + p.save! + end + end + + subject(:aggregated) { described_class.new(project: portfolio, current_user: user) } + + before do + # Ensure child projects are loaded before creating memberships + child_project_one + child_project_two + portfolio.reload + + # Create membership for user in portfolio and child projects + [portfolio, child_project_one, child_project_two].each do |p| + create(:member, project: p, user:, + roles: [create(:project_role, permissions: %i[view_budgets])]) + end + end + + context "with budgets in child projects" do + let!(:budget1) { create(:budget, project: child_project_one, base_amount: BigDecimal("5000")) } + let!(:budget2) { create(:budget, project: child_project_two, base_amount: BigDecimal("3000")) } + + it "aggregates budget count from all child projects" do + expect(aggregated.budget_count).to eq(2) + end + + it "aggregates base amounts from all child projects" do + expect(aggregated.budgeted_base).to eq(BigDecimal("8000")) + end + end + end +end diff --git a/modules/budgets/spec/models/budgets/aggregated_budgets_with_spend_spec.rb b/modules/budgets/spec/models/budgets/aggregated_budgets_with_spend_spec.rb new file mode 100644 index 00000000000..1e0770e599a --- /dev/null +++ b/modules/budgets/spec/models/budgets/aggregated_budgets_with_spend_spec.rb @@ -0,0 +1,292 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Budgets::AggregatedBudgetsWithSpend do + shared_let(:project) { create(:project_with_types) } + shared_let(:user) do + create(:user, + member_with_permissions: { project => %i[view_budgets + view_cost_entries + view_cost_rates + view_time_entries + view_hourly_rates] }) + end + shared_let(:work_package) { create(:work_package, project: project) } + + subject(:aggregated) do + described_class.new(project:, current_user: user) + end + + describe "#spent_total" do + context "with no spending" do + it "returns 0" do + expect(aggregated.spent_total).to eq(0) + end + end + + context "with both material and labor spending" do + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: Date.current - 1.day, + rate: 50.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, + project:, + user:, + cost_type:, + units: 20, + spent_on: Date.current) + end + let!(:time_entry) do + create(:time_entry, + entity: work_package, + project:, + user:, + hours: 20, + spent_on: Date.current) + end + + before do + create(:hourly_rate, + user:, + project:, + rate: 50.0, + valid_from: Date.current - 1.day) + end + + it "sums material and labor costs" do + expect(aggregated.spent_total).to eq(BigDecimal("2000")) + end + end + end + + describe "#spent_ratio" do + context "with zero budget total" do + it "returns 0 without raising error" do + expect(aggregated.spent_ratio).to eq(BigDecimal("0")) + end + end + + context "with normal spending" do + let!(:budget) { create(:budget, project:, base_amount: BigDecimal("10000")) } + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: Date.current - 1.day, + rate: 250.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, + project:, + user:, + cost_type:, + units: 20, + spent_on: Date.current) + end + + it "calculates the ratio of spent to budget" do + expect(aggregated.spent_ratio).to eq(BigDecimal("0.5")) + end + end + + context "when over budget" do + let!(:budget) { create(:budget, project:, base_amount: BigDecimal("10000")) } + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: Date.current - 1.day, + rate: 750.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, + project:, + user:, + cost_type:, + units: 20, + spent_on: Date.current) + end + + it "returns a ratio greater than 1" do + expect(aggregated.spent_ratio).to eq(BigDecimal("1.5")) + end + end + + context "with no spending" do + let!(:budget) { create(:budget, project:, base_amount: BigDecimal("10000")) } + + it "returns 0" do + expect(aggregated.spent_ratio).to eq(BigDecimal("0")) + end + end + end + + describe "#remaining" do + context "with positive remaining budget" do + let!(:budget) { create(:budget, project:, base_amount: BigDecimal("10000")) } + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: Date.current - 1.day, + rate: 150.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, + project:, + user:, + cost_type:, + units: 20, + spent_on: Date.current) + end + + it "calculates remaining as budgeted_total - spent_total" do + expect(aggregated.remaining).to eq(BigDecimal("7000")) + end + end + + context "when at budget" do + let!(:budget) { create(:budget, project:, base_amount: BigDecimal("10000")) } + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: Date.current - 1.day, + rate: 500.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, + project:, + user:, + cost_type:, + units: 20, + spent_on: Date.current) + end + + it "returns 0" do + expect(aggregated.remaining).to eq(BigDecimal("0")) + end + end + + context "when over budget" do + let!(:budget) { create(:budget, project:, base_amount: BigDecimal("10000")) } + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: Date.current - 1.day, + rate: 600.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, + project:, + user:, + cost_type:, + units: 20, + spent_on: Date.current) + end + + it "returns negative value" do + expect(aggregated.remaining).to eq(BigDecimal("-2000")) + end + end + + context "with no spending" do + let!(:budget) { create(:budget, project:, base_amount: BigDecimal("10000")) } + + it "returns the total budget" do + expect(aggregated.remaining).to eq(BigDecimal("10000")) + end + end + end + + describe "delegation" do + let!(:budget) { create(:budget, project:, base_amount: BigDecimal("10000")) } + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: Date.current - 1.day, + rate: 100.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, + project:, + user:, + cost_type:, + units: 10, + spent_on: Date.current) + end + let!(:labor_item) do + create(:labor_budget_item, + budget:, + user:, + hours: 100, + amount: BigDecimal("5000")) + end + + before do + create(:hourly_rate, + user:, + project:, + rate: 50.0, + valid_from: Date.current - 1.day) + end + + it "delegates budget_count to Budgets::AggregatedBudgets" do + expect(aggregated.budget_count).to eq(1) + end + + it "delegates budgeted_base to Budgets::AggregatedBudgets" do + expect(aggregated.budgeted_base).to eq(BigDecimal("10000")) + end + + it "delegates budgeted_labor to Budgets::AggregatedBudgets" do + expect(aggregated.budgeted_labor).to eq(BigDecimal("5000")) + end + + it "delegates spent_material to Costs::AggregatedCosts" do + expect(aggregated.spent_material).to eq(BigDecimal("1000")) + end + end +end diff --git a/modules/costs/app/components/costs/widget_component.rb b/modules/costs/app/components/costs/widget_component.rb new file mode 100644 index 00000000000..686c5f0325c --- /dev/null +++ b/modules/costs/app/components/costs/widget_component.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Costs + class WidgetComponent < Grids::WidgetComponent + param :project + + def render? + project.module_enabled?(:costs) && has_required_permissions? + end + + private + + def has_required_permissions? + true + end + end +end diff --git a/modules/costs/app/components/costs/widgets/actual_costs.html.erb b/modules/costs/app/components/costs/widgets/actual_costs.html.erb new file mode 100644 index 00000000000..36237d52fdd --- /dev/null +++ b/modules/costs/app/components/costs/widgets/actual_costs.html.erb @@ -0,0 +1,61 @@ +<%# -- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++# %> + +<%= + widget_wrapper do + if has_spending? + flex_layout do |flex| + flex.with_row(mt: 3, align: :center) do + helpers.angular_component_tag( + "opce-actual-costs", + currency: Setting.costs_currency, + "chart-data": { + labels: chart_labels, + datasets: chart_datasets + }.to_json + ) + end + end + else + render(Primer::Beta::Blankslate.new) do |blankslate| + blankslate.with_visual_icon(icon: :"op-cost-reports") + blankslate.with_heading(tag: :h3).with_content(t(".blankslate.heading")) + blankslate.with_description_content(t(".blankslate.description")) + blankslate.with_primary_action( + href: dialog_time_entries_path, + scheme: :secondary, + data: { turbo_stream: true } + ) do |button| + button.with_leading_visual_icon(icon: :clock) + t(".blankslate.action") + end + end + end + end +%> diff --git a/modules/costs/app/components/costs/widgets/actual_costs.rb b/modules/costs/app/components/costs/widgets/actual_costs.rb new file mode 100644 index 00000000000..e2b491a5a8e --- /dev/null +++ b/modules/costs/app/components/costs/widgets/actual_costs.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Costs + module Widgets + class ActualCosts < Costs::WidgetComponent + REQUIRED_PERMISSIONS = %i[view_cost_entries view_cost_rates view_time_entries view_hourly_rates].freeze + + delegate :has_spending?, :months, :cost_type_names, + :spent_labor_by_month, :spent_material_by_month_and_type, + to: :@aggregated_costs + + def initialize(...) + super + + @aggregated_costs = Costs::AggregatedCosts.new(project:, current_user:, date_range: Date.current.all_year) + end + + def title + t(".title") + end + + def chart_labels + months + end + + def chart_datasets + return [] unless months.any? + + [labor_dataset] + material_datasets + end + + private + + def has_required_permissions? + REQUIRED_PERMISSIONS.all? { |perm| current_user.allowed_in_project?(perm, project) } + end + + def labor_dataset + { + label: t(:caption_labor), + data: months.map { |month| spent_labor_by_month.fetch(month, 0.0) } + } + end + + def material_datasets + cost_type_names.map do |cost_type_name| + { + label: cost_type_name, + data: months.map { |month| spent_material_by_month_and_type.fetch([month, cost_type_name], 0.0) } + } + end + end + end + end +end diff --git a/modules/costs/app/models/cost_entry.rb b/modules/costs/app/models/cost_entry.rb index 96d6b8a90ca..799c247be64 100644 --- a/modules/costs/app/models/cost_entry.rb +++ b/modules/costs/app/models/cost_entry.rb @@ -36,6 +36,7 @@ class CostEntry < ApplicationRecord belongs_to :user belongs_to :logged_by, class_name: "User" include ::Costs::DeletedUserFallback + belongs_to :cost_type belongs_to :budget belongs_to :rate, class_name: "CostRate" @@ -56,6 +57,10 @@ class CostEntry < ApplicationRecord scope :on_work_packages, ->(work_packages) { where(entity: work_packages) } + def self.effective_costs_sum + sum(arel_table.coalesce(arel_table[:overridden_costs], arel_table[:costs])) + end + extend CostEntryScopes include Entry::Costs include Entry::SplashedDates diff --git a/modules/costs/app/models/costs/aggregated_costs.rb b/modules/costs/app/models/costs/aggregated_costs.rb new file mode 100644 index 00000000000..fe6479d67b5 --- /dev/null +++ b/modules/costs/app/models/costs/aggregated_costs.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Costs + class AggregatedCosts + include Budgets::ProjectAggregation + + def initialize(project:, current_user: User.current, date_range: nil) + @project = project + @current_user = current_user + @date_range = date_range + end + + def months + labor_months = spent_labor_by_month.keys + material_months = spent_material_by_month_and_type.keys.map(&:first) + (labor_months | material_months).sort.freeze + end + + def cost_type_names + spent_material_by_month_and_type.keys.map(&:last).uniq + end + + def spent_total + spent_material + spent_labor + end + + def spent_material + cost_entries.effective_costs_sum + end + + def spent_material_by_month_and_type + cost_entries_by_month_and_type.effective_costs_sum + end + + def spent_labor + time_entries.effective_costs_sum + end + + def spent_labor_by_month + time_entries_by_month.effective_costs_sum + end + + def has_spending? + cost_entries.exists? || time_entries.exists? + end + + private + + def cost_entries + scope = CostEntry.joins(:project).merge(applicable_projects).visible_costs(current_user) + scope = scope.where(spent_on: @date_range) if @date_range + scope + end + + def cost_entries_by_month_and_type + cost_entries + .joins(:cost_type) + .group( + "date_trunc('month', cost_entries.spent_on)", + "cost_types.name" + ) + .order("cost_types.name ASC") + end + + def time_entries + scope = TimeEntry.joins(:project).merge(applicable_projects).visible_costs(current_user) + scope = scope.where(spent_on: @date_range) if @date_range + scope + end + + def time_entries_by_month + time_entries.group("date_trunc('month', time_entries.spent_on)") + end + end +end diff --git a/modules/costs/app/models/time_entry.rb b/modules/costs/app/models/time_entry.rb index 1eeb0ef36c6..e35efcd15c0 100644 --- a/modules/costs/app/models/time_entry.rb +++ b/modules/costs/app/models/time_entry.rb @@ -103,6 +103,10 @@ class TimeEntry < ApplicationRecord register_journal_formatted_fields "entity_gid", formatter_key: :polymorphic_association register_journal_formatted_fields "comments", "spent_on", "start_time", formatter_key: :plaintext + def self.effective_costs_sum + sum(arel_table.coalesce(arel_table[:overridden_costs], arel_table[:costs])) + end + def self.update_all(updates, conditions = nil, options = {}) # instead of a update_all, perform an individual update during work_package#move # to trigger the update of the costs based on new rates diff --git a/modules/costs/config/locales/en.yml b/modules/costs/config/locales/en.yml index 6556d5d8785..b6a6f69e712 100644 --- a/modules/costs/config/locales/en.yml +++ b/modules/costs/config/locales/en.yml @@ -250,6 +250,15 @@ en: validation: start_time_different_date: "Date part of startTime (%{start_time}) must be the same as the spentOn (%{spent_on}) date." + costs: + widgets: + actual_costs: + title: "Actual costs" + blankslate: + heading: "Start tracking your time and costs" + description: "Get an overview of your costs and logged time to monitor progress of your project" + action: "Log time" + ee: features: time_entry_time_restrictions: Require exact time tracking diff --git a/modules/costs/spec/components/costs/widgets/actual_costs_spec.rb b/modules/costs/spec/components/costs/widgets/actual_costs_spec.rb new file mode 100644 index 00000000000..03f2176e01e --- /dev/null +++ b/modules/costs/spec/components/costs/widgets/actual_costs_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Costs::Widgets::ActualCosts, type: :component do + def render_component(...) + render_inline(described_class.new(...)) + end + + let(:project) { create(:project_with_types) } + let(:current_user) do + create(:user, + member_with_permissions: { project => %i[view_cost_entries + view_cost_rates + view_time_entries + view_hourly_rates] }) + end + + subject(:rendered_component) { render_component(project, current_user:) } + + context "with spending data" do + let(:work_package) { create(:work_package, project: project) } + let!(:hourly_rate) do + create(:hourly_rate, + user: current_user, + project: project, + rate: 50.0, + valid_from: 1.month.ago) + end + let!(:time_entry) do + create(:time_entry, + entity: work_package, + project: project, + user: current_user, + hours: 40, + spent_on: Date.current) + end + + it "renders angular component" do + expect(rendered_component).to have_css("opce-actual-costs") + end + + it "passes currency attribute" do + expect(rendered_component).to have_element "opce-actual-costs" do |element| + expect(element["currency"]).to eq(Setting.costs_currency) + end + end + + it "passes chart data with correct structure" do + expect(rendered_component).to have_element "opce-actual-costs" do |element| + chart_data_json = element["chart-data"] + expect(chart_data_json).to be_present + + chart_data = JSON.parse(chart_data_json) + expect(chart_data).to have_key("labels") + expect(chart_data).to have_key("datasets") + expect(chart_data["labels"]).to be_an(Array) + expect(chart_data["datasets"]).to be_an(Array) + + # Verify labor dataset exists + labor_dataset = chart_data["datasets"].find { |ds| ds["label"] == I18n.t(:caption_labor) } + expect(labor_dataset).to be_present + expect(labor_dataset["data"]).to be_an(Array) + end + end + + it "includes actual cost data in chart datasets" do + expect(rendered_component).to have_element "opce-actual-costs" do |element| + chart_data = JSON.parse(element["chart-data"]) + + expect(chart_data["labels"]).not_to be_empty + expect(chart_data["datasets"]).not_to be_empty + + # Verify labor dataset has data + labor_dataset = chart_data["datasets"].first + expect(labor_dataset["data"]).not_to be_empty + expect(labor_dataset["data"].sum(&:to_f)).to be > 0 + end + end + end + + context "with material cost entries" do + let(:work_package) { create(:work_package, project: project) } + let(:cost_type) { create(:cost_type, name: "Development") } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: 1.month.ago, + rate: 100.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, + project:, + user: current_user, + cost_type:, + units: 20, + spent_on: Date.current) + end + + it "includes material cost data in chart datasets" do + expect(rendered_component).to have_element "opce-actual-costs" do |element| + chart_data = JSON.parse(element["chart-data"]) + material_dataset = chart_data["datasets"].find { |ds| ds["label"] == "Development" } + + expect(material_dataset).to be_present + expect(material_dataset["data"].sum(&:to_f)).to be > 0 + end + end + end + + context "with spending data only from a prior year" do + let(:work_package) { create(:work_package, project: project) } + let!(:hourly_rate) do + create(:hourly_rate, + user: current_user, + project: project, + rate: 50.0, + valid_from: 2.years.ago) + end + let!(:time_entry_last_year) do + create(:time_entry, + entity: work_package, + project: project, + user: current_user, + hours: 40, + spent_on: 1.year.ago.to_date) + end + + it "shows the blankslate instead of the chart" do + expect(rendered_component).to have_no_css("opce-actual-costs") + expect(rendered_component).to have_css(".blankslate") + end + end + + context "without spending data" do + it_behaves_like "rendering Blank Slate", + heading: I18n.t("costs.widgets.actual_costs.blankslate.heading") + end + + context "without proper permissions" do + let(:current_user) do + create(:user, member_with_permissions: { project => %i[view_work_packages] }) + end + + it "renders nothing" do + expect(rendered_component.to_s).to be_empty + end + end + + context "with partial permissions" do + let(:current_user) do + create(:user, member_with_permissions: { project => %i[view_time_entries] }) + end + + it "renders nothing when missing cost entry permissions" do + expect(rendered_component.to_s).to be_empty + end + end + + describe "#wrapper_arguments" do + let(:component) { described_class.new(project) } + + it "returns empty hash" do + expect(component.wrapper_arguments).to eq({}) + end + end +end diff --git a/modules/costs/spec/models/costs/aggregated_costs_spec.rb b/modules/costs/spec/models/costs/aggregated_costs_spec.rb new file mode 100644 index 00000000000..43813f6120b --- /dev/null +++ b/modules/costs/spec/models/costs/aggregated_costs_spec.rb @@ -0,0 +1,762 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Costs::AggregatedCosts do + shared_let(:project) { create(:project_with_types) } + shared_let(:user) do + create(:user, + member_with_permissions: { project => %i[view_cost_entries + view_cost_rates + view_time_entries + view_hourly_rates + view_own_hourly_rate] }) + end + shared_let(:work_package) { create(:work_package, project: project) } + + subject(:aggregated) { described_class.new(project:, current_user: user) } + + describe "#spent_material" do + context "with no cost entries" do + it "returns 0" do + expect(aggregated.spent_material).to eq(0) + end + end + + context "with cost entries" do + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: Date.current - 1.day, + rate: 100.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, + project:, + user:, + cost_type:, + units: 20, + spent_on: Date.current) + end + + it "sums effective costs" do + expect(aggregated.spent_material).to eq(BigDecimal("2000")) + end + end + + context "with overridden costs" do + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: Date.current - 1.day, + rate: 100.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, + project:, + user:, + cost_type:, + units: 20, + spent_on: Date.current, + overridden_costs: BigDecimal("2500")) + end + + it "uses overridden costs" do + expect(aggregated.spent_material).to eq(BigDecimal("2500")) + end + end + + context "without view_cost_rates permission" do + let(:user_without_cost_rates) do + create(:user, member_with_permissions: { project => %i[view_cost_entries] }) + end + let(:aggregated_without_cost_rates) { described_class.new(project:, current_user: user_without_cost_rates) } + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: Date.current - 1.day, + rate: 100.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, + project: project, + user: user_without_cost_rates, + cost_type:, + units: 20, + spent_on: Date.current) + end + + it "returns 0" do + expect(aggregated_without_cost_rates.spent_material).to eq(0) + end + end + end + + describe "#spent_labor" do + context "with no time entries" do + it "returns 0" do + expect(aggregated.spent_labor).to eq(0) + end + end + + context "with time entries" do + let!(:time_entry) do + create(:time_entry, + entity: work_package, + project:, + user:, + hours: 40, + spent_on: Date.current) + end + + before do + create(:hourly_rate, + user:, + project:, + rate: 50.0, + valid_from: Date.current - 1.day) + end + + it "sums effective costs" do + expect(aggregated.spent_labor).to eq(BigDecimal("2000")) + end + end + + context "without view_hourly_rates permission" do + let(:user_without_hourly_rates) do + create(:user, member_with_permissions: { project => %i[view_time_entries] }) + end + let(:aggregated_without_hourly_rates) { described_class.new(project:, current_user: user_without_hourly_rates) } + let!(:time_entry) do + create(:time_entry, + entity: work_package, + project: project, + user: user_without_hourly_rates, + hours: 40, + spent_on: Date.current) + end + + before do + create(:hourly_rate, + user: user_without_hourly_rates, + project: project, + rate: 50.0, + valid_from: Date.current - 1.day) + end + + it "returns 0" do + expect(aggregated_without_hourly_rates.spent_labor).to eq(0) + end + end + + context "with view_own_hourly_rate permission only" do + let(:user_with_own_rate_only) do + create(:user, + member_with_permissions: { project => %i[view_time_entries view_own_hourly_rate] }) + end + let(:other_user) do + create(:user, member_with_permissions: { project => %i[view_time_entries] }) + end + let(:aggregated_with_own_rate) { described_class.new(project:, current_user: user_with_own_rate_only) } + let!(:own_time_entry) do + create(:time_entry, + entity: work_package, + project: project, + user: user_with_own_rate_only, + hours: 40, + spent_on: Date.current) + end + let!(:other_time_entry) do + create(:time_entry, + entity: work_package, + project: project, + user: other_user, + hours: 20, + spent_on: Date.current) + end + + before do + create(:hourly_rate, + user: user_with_own_rate_only, + project: project, + rate: 50.0, + valid_from: Date.current - 1.day) + create(:hourly_rate, + user: other_user, + project: project, + rate: 60.0, + valid_from: Date.current - 1.day) + end + + it "includes only user's own time entries" do + expect(aggregated_with_own_rate.spent_labor).to eq(BigDecimal("2000")) + end + end + end + + describe "#spent_total" do + context "with no spending" do + it "returns 0" do + expect(aggregated.spent_total).to eq(0) + end + end + + context "with both material and labor spending" do + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: Date.current - 1.day, + rate: 50.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, + project:, + user:, + cost_type:, + units: 20, + spent_on: Date.current) + end + let!(:time_entry) do + create(:time_entry, + entity: work_package, + project:, + user:, + hours: 20, + spent_on: Date.current) + end + + before do + create(:hourly_rate, + user:, + project:, + rate: 50.0, + valid_from: Date.current - 1.day) + end + + it "sums material and labor costs" do + expect(aggregated.spent_total).to eq(BigDecimal("2000")) + end + end + + context "with only material spending" do + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: Date.current - 1.day, + rate: 50.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, + project:, + user:, + cost_type:, + units: 20, + spent_on: Date.current) + end + + it "returns only material costs" do + expect(aggregated.spent_total).to eq(BigDecimal("1000")) + end + end + + context "with only labor spending" do + let!(:time_entry) do + create(:time_entry, + entity: work_package, + project:, + user:, + hours: 20, + spent_on: Date.current) + end + + before do + create(:hourly_rate, + user:, + project:, + rate: 50.0, + valid_from: Date.current - 1.day) + end + + it "returns only labor costs" do + expect(aggregated.spent_total).to eq(BigDecimal("1000")) + end + end + end + + describe "#spent_material_by_month_and_type" do + context "with cost entries in different months" do + let!(:cost_type_a) { create(:cost_type, name: "Materials A") } + let!(:cost_type_b) { create(:cost_type, name: "Materials B") } + let!(:cost_rate_a) do + create(:cost_rate, + cost_type: cost_type_a, + valid_from: Date.new(2025, 1, 1), + rate: 100.0) + end + let!(:cost_rate_b) do + create(:cost_rate, + cost_type: cost_type_b, + valid_from: Date.new(2025, 1, 1), + rate: 100.0) + end + let!(:entry_jan_a) do + create(:cost_entry, + entity: work_package, + project: project, + user:, + cost_type: cost_type_a, + units: 10, + spent_on: Date.new(2025, 1, 15)) + end + let!(:entry_feb_a) do + create(:cost_entry, + entity: work_package, + project: project, + user:, + cost_type: cost_type_a, + units: 15, + spent_on: Date.new(2025, 2, 10)) + end + let!(:entry_jan_b) do + create(:cost_entry, + entity: work_package, + project: project, + user:, + cost_type: cost_type_b, + units: 5, + spent_on: Date.new(2025, 1, 20)) + end + + it "groups costs by month and cost type" do + result = aggregated.spent_material_by_month_and_type + expect(result.count).to eq(3) + end + + it "returns keys as [month, cost_type_name] pairs" do + result = aggregated.spent_material_by_month_and_type + expect(result.keys).to all(be_an(Array).and(have_attributes(size: 2))) + end + + it "calculates sums for each group" do + result = aggregated.spent_material_by_month_and_type + total = result.sum { |_key, value| value } + expect(total).to eq(BigDecimal("3000")) + end + end + + context "with no cost entries" do + it "returns empty collection" do + result = aggregated.spent_material_by_month_and_type + expect(result.count).to eq(0) + end + end + end + + describe "#spent_labor_by_month" do + context "with time entries in different months" do + let!(:entry_jan) do + create(:time_entry, + entity: work_package, + project: project, + user:, + hours: 40, + spent_on: Date.new(2025, 1, 15)) + end + let!(:entry_feb) do + create(:time_entry, + entity: work_package, + project: project, + user:, + hours: 30, + spent_on: Date.new(2025, 2, 10)) + end + + before do + create(:hourly_rate, + user:, + project:, + rate: 50.0, + valid_from: Date.new(2025, 1, 1)) + end + + it "groups costs by month" do + result = aggregated.spent_labor_by_month + expect(result.count).to eq(2) + end + + it "calculates sums for each month" do + result = aggregated.spent_labor_by_month + total = result.sum { |_key, value| value } + expect(total).to eq(BigDecimal("3500")) + end + end + + context "with no time entries" do + it "returns empty collection" do + result = aggregated.spent_labor_by_month + expect(result.count).to eq(0) + end + end + end + + describe "#months" do + context "with both labor and material entries" do + let!(:cost_type) { create(:cost_type, name: "Materials") } + let!(:cost_rate) do + create(:cost_rate, cost_type:, valid_from: Date.new(2025, 1, 1), rate: 100.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, project:, user:, cost_type:, + units: 10, spent_on: Date.new(2025, 3, 15)) + end + let!(:time_entry) do + create(:time_entry, + entity: work_package, project:, user:, + hours: 10, spent_on: Date.new(2025, 1, 10)) + end + + before do + create(:hourly_rate, user:, project:, rate: 50.0, valid_from: Date.new(2025, 1, 1)) + end + + it "returns the sorted union of labor and material months" do + expect(aggregated.months).to eq( + [ + Time.zone.parse("2025-01-01"), + Time.zone.parse("2025-03-01") + ] + ) + end + end + + context "with no entries" do + it "returns an empty array" do + expect(aggregated.months).to eq([]) + end + end + end + + describe "#cost_type_names" do + context "with material entries of different types" do + let!(:cost_type_a) { create(:cost_type, name: "Concrete") } + let!(:cost_type_b) { create(:cost_type, name: "Steel") } + let!(:cost_rate_a) do + create(:cost_rate, cost_type: cost_type_a, valid_from: Date.new(2025, 1, 1), rate: 100.0) + end + let!(:cost_rate_b) do + create(:cost_rate, cost_type: cost_type_b, valid_from: Date.new(2025, 1, 1), rate: 100.0) + end + let!(:entry_a) do + create(:cost_entry, + entity: work_package, project:, user:, cost_type: cost_type_a, + units: 10, spent_on: Date.new(2025, 1, 15)) + end + let!(:entry_b) do + create(:cost_entry, + entity: work_package, project:, user:, cost_type: cost_type_b, + units: 5, spent_on: Date.new(2025, 1, 20)) + end + + it "returns unique cost type names" do + expect(aggregated.cost_type_names).to contain_exactly("Concrete", "Steel") + end + end + + context "with no material entries" do + it "returns an empty array" do + expect(aggregated.cost_type_names).to eq([]) + end + end + end + + describe "date_range filtering" do + let(:date_range) { Date.new(2025, 1, 1)..Date.new(2025, 12, 31) } + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, cost_type:, valid_from: Date.new(2024, 1, 1), rate: 100.0) + end + + subject(:aggregated) { described_class.new(project:, current_user: user, date_range:) } + + before do + create(:hourly_rate, user:, project:, rate: 50.0, valid_from: Date.new(2024, 1, 1)) + end + + context "with entries inside and outside the date range" do + let!(:cost_entry_in_range) do + create(:cost_entry, + entity: work_package, project:, user:, cost_type:, + units: 10, spent_on: Date.new(2025, 6, 15)) + end + let!(:cost_entry_out_of_range) do + create(:cost_entry, + entity: work_package, project:, user:, cost_type:, + units: 20, spent_on: Date.new(2024, 6, 15)) + end + let!(:time_entry_in_range) do + create(:time_entry, + entity: work_package, project:, user:, + hours: 10, spent_on: Date.new(2025, 3, 10)) + end + let!(:time_entry_out_of_range) do + create(:time_entry, + entity: work_package, project:, user:, + hours: 20, spent_on: Date.new(2024, 3, 10)) + end + + it "includes only material costs within the date range" do + expect(aggregated.spent_material).to eq(BigDecimal("1000")) + end + + it "includes only labor costs within the date range" do + expect(aggregated.spent_labor).to eq(BigDecimal("500")) + end + + it "scopes has_spending? to the date range" do + expect(aggregated.has_spending?).to be(true) + end + end + + context "with entries only outside the date range" do + let!(:cost_entry_out_of_range) do + create(:cost_entry, + entity: work_package, project:, user:, cost_type:, + units: 10, spent_on: Date.new(2024, 6, 15)) + end + + it "returns false for has_spending?" do + expect(aggregated.has_spending?).to be(false) + end + + it "returns 0 for spent_total" do + expect(aggregated.spent_total).to eq(0) + end + end + + context "without a date_range (nil)" do + subject(:aggregated) { described_class.new(project:, current_user: user) } + + let!(:cost_entry_2024) do + create(:cost_entry, + entity: work_package, project:, user:, cost_type:, + units: 10, spent_on: Date.new(2024, 6, 15)) + end + let!(:cost_entry_2025) do + create(:cost_entry, + entity: work_package, project:, user:, cost_type:, + units: 10, spent_on: Date.new(2025, 6, 15)) + end + + it "includes all entries regardless of date" do + expect(aggregated.spent_material).to eq(BigDecimal("2000")) + end + end + end + + describe "#has_spending?" do + context "with no entries" do + it "returns false" do + expect(aggregated.has_spending?).to be(false) + end + end + + context "with cost entries only" do + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: Date.current - 1.day, + rate: 100.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, + project:, + user:, + cost_type:, + units: 10, + spent_on: Date.current) + end + + it "returns true" do + expect(aggregated.has_spending?).to be(true) + end + end + + context "with time entries only" do + let!(:time_entry) do + create(:time_entry, + entity: work_package, + project:, + user:, + hours: 20, + spent_on: Date.current) + end + + it "returns true" do + expect(aggregated.has_spending?).to be(true) + end + end + + context "with both cost and time entries" do + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: Date.current - 1.day, + rate: 100.0) + end + let!(:cost_entry) do + create(:cost_entry, + entity: work_package, + project:, + user:, + cost_type:, + units: 10, + spent_on: Date.current) + end + let!(:time_entry) do + create(:time_entry, + entity: work_package, + project:, + user:, + hours: 20, + spent_on: Date.current) + end + + it "returns true" do + expect(aggregated.has_spending?).to be(true) + end + end + end + + describe "portfolio project support" do + let(:portfolio) do + create(:portfolio).tap do |p| + p.enabled_module_names += %w[costs] + p.save! + end + end + let(:child_project_one) do + create(:project_with_types, parent: portfolio).tap do |p| + p.enabled_module_names += %w[costs] + p.save! + end + end + let(:child_project_two) do + create(:project_with_types, parent: portfolio).tap do |p| + p.enabled_module_names += %w[costs] + p.save! + end + end + + subject(:aggregated) { described_class.new(project: portfolio, current_user: user) } + + before do + # Ensure child projects are loaded before creating memberships + child_project_one + child_project_two + portfolio.reload + + # Create membership for user in portfolio project + create(:member, + project: portfolio, + user:, + roles: [create(:project_role, + permissions: %i[view_cost_entries + view_cost_rates + view_time_entries + view_hourly_rates])]) + # Create memberships for user in child projects + create(:member, + project: child_project_one, + user:, + roles: [create(:project_role, + permissions: %i[view_cost_entries + view_cost_rates + view_time_entries + view_hourly_rates])]) + create(:member, + project: child_project_two, + user:, + roles: [create(:project_role, + permissions: %i[view_cost_entries + view_cost_rates + view_time_entries + view_hourly_rates])]) + end + + context "with spending in child projects" do + let!(:wp1) { create(:work_package, project: child_project_one) } + let!(:wp2) { create(:work_package, project: child_project_two) } + let!(:cost_type) { create(:cost_type) } + let!(:cost_rate) do + create(:cost_rate, + cost_type:, + valid_from: Date.current - 1.day, + rate: 100.0) + end + let!(:cost_entry1) do + create(:cost_entry, + entity: wp1, + project: child_project_one, + user:, + cost_type:, + units: 10, + spent_on: Date.current) + end + let!(:cost_entry2) do + create(:cost_entry, + entity: wp2, + project: child_project_two, + user:, + cost_type:, + units: 15, + spent_on: Date.current) + end + + it "aggregates spending from all child projects" do + expect(aggregated.spent_material).to eq(BigDecimal("2500")) + end + end + end +end diff --git a/modules/grids/app/components/grids/widget_box_component.rb b/modules/grids/app/components/grids/widget_box_component.rb index 09c9fd91b55..5130b74954a 100644 --- a/modules/grids/app/components/grids/widget_box_component.rb +++ b/modules/grids/app/components/grids/widget_box_component.rb @@ -54,6 +54,8 @@ module Grids turbo_enabled: true, content_padding: Body::DEFAULT_PADDING, full_width: false, + half_width: false, + border: true, **system_arguments ) super() @@ -68,7 +70,9 @@ module Grids @system_arguments[:classes] = class_names( @system_arguments[:classes], "widget-box", - "widget-box_full-width" => full_width + "widget-box_full-width" => full_width, + "widget-box_half-width" => half_width, + "-no-border" => !border ) @system_arguments[:id] ||= "#{key}-box" diff --git a/modules/overviews/app/components/overviews/workspaces/portfolio_overview_grid_component.html.erb b/modules/overviews/app/components/overviews/workspaces/portfolio_overview_grid_component.html.erb index 57f6542b340..8fa7952215c 100644 --- a/modules/overviews/app/components/overviews/workspaces/portfolio_overview_grid_component.html.erb +++ b/modules/overviews/app/components/overviews/workspaces/portfolio_overview_grid_component.html.erb @@ -33,6 +33,9 @@ See COPYRIGHT and LICENSE files for more details. grid.with_widget(Grids::Widgets::ProjectStatus, @portfolio) grid.with_widget(Grids::Widgets::Subitems, @portfolio) grid.with_widget(Grids::Widgets::Members, @portfolio) + grid.with_widget(Budgets::Widgets::BudgetTotals, @portfolio) + grid.with_widget(Budgets::Widgets::BudgetByCostType, @portfolio) + grid.with_widget(Costs::Widgets::ActualCosts, @portfolio) grid.with_widget(Grids::ProjectAttributeWidgets, @portfolio) end diff --git a/modules/overviews/app/components/overviews/workspaces/program_overview_grid_component.html.erb b/modules/overviews/app/components/overviews/workspaces/program_overview_grid_component.html.erb index b6cac8cc80c..32b46789123 100644 --- a/modules/overviews/app/components/overviews/workspaces/program_overview_grid_component.html.erb +++ b/modules/overviews/app/components/overviews/workspaces/program_overview_grid_component.html.erb @@ -33,6 +33,9 @@ See COPYRIGHT and LICENSE files for more details. grid.with_widget(Grids::Widgets::ProjectStatus, @program) grid.with_widget(Grids::Widgets::Subitems, @program) grid.with_widget(Grids::Widgets::Members, @program) + grid.with_widget(Budgets::Widgets::BudgetTotals, @program) + grid.with_widget(Budgets::Widgets::BudgetByCostType, @program) + grid.with_widget(Costs::Widgets::ActualCosts, @program) grid.with_widget(Grids::ProjectAttributeWidgets, @program) end diff --git a/modules/overviews/app/components/overviews/workspaces/project_overview_grid_component.html.erb b/modules/overviews/app/components/overviews/workspaces/project_overview_grid_component.html.erb index e8a8ebad820..1d356d8e866 100644 --- a/modules/overviews/app/components/overviews/workspaces/project_overview_grid_component.html.erb +++ b/modules/overviews/app/components/overviews/workspaces/project_overview_grid_component.html.erb @@ -34,6 +34,9 @@ See COPYRIGHT and LICENSE files for more details. grid.with_widget(Grids::Widgets::Subitems, @project) grid.with_widget(Grids::Widgets::Members, @project) grid.with_widget(Grids::Widgets::News, @project) + grid.with_widget(Budgets::Widgets::BudgetTotals, @project) + grid.with_widget(Budgets::Widgets::BudgetByCostType, @project) + grid.with_widget(Costs::Widgets::ActualCosts, @project) grid.with_widget(Grids::ProjectAttributeWidgets, @project) end From 45a5519e618a4b6983caf13772dfa258cad21e3e Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 23 Jan 2026 21:27:55 -0300 Subject: [PATCH 03/21] Format tooltip date without time in Actual Costs chart Display dates in tooltips as "Jan 2026" (month and year only). Co-Authored-By: Claude Opus 4.5 --- .../budget-graphs/overview/actual-costs.component.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts index cc85ecea391..0a90a822e8c 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts @@ -74,6 +74,15 @@ export class ActualCostsComponent { enabled: false, external: renderChartTooltip, callbacks: { + title: (context) => { + const timestamp = context[0].parsed.x; + if (timestamp === null) return ''; + const date = new Date(timestamp); + return date.toLocaleDateString(undefined, { + month: 'short', + year: 'numeric', + }); + }, label: (context) => { const label = context.dataset.label ?? ''; const value = context.raw as number; From d7687ea4d0cf8535e7cb4ac4f763752604c55629 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 23 Jan 2026 21:27:45 -0300 Subject: [PATCH 04/21] Set aspectRatio: 1 for Actual Costs bar chart Make the bar chart square to match the pie chart's proportions. Co-Authored-By: Claude Opus 4.5 --- .../components/budget-graphs/overview/actual-costs.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts index 0a90a822e8c..c3007d8e73c 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts @@ -53,6 +53,7 @@ export class ActualCostsComponent { readonly barChartOptions = computed['options']>(() => ({ font: chartFont, + aspectRatio: 1.5, scales: { x: { stacked: true, From 5de7e04662cef81c9bbfadbed6da45d0be28f3fa Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 10 Feb 2026 22:48:44 -0300 Subject: [PATCH 05/21] Fix TS2742 declaration errors in chart components Add explicit `Signal` property type annotations to `lineChartOptions`, `barChartOptions`, and `pieChartOptions` so tsc can emit declaration files without referencing the internal `chart.js/dist/types/utils` module. Co-Authored-By: Claude Sonnet 4.5 --- .../src/app/features/backlogs/burndown-chart.component.ts | 4 ++-- .../budget-graphs/overview/actual-costs.component.ts | 3 ++- .../budget-graphs/overview/budget-by-cost-type.component.ts | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/features/backlogs/burndown-chart.component.ts b/frontend/src/app/features/backlogs/burndown-chart.component.ts index 7989f667c4c..185740556dc 100644 --- a/frontend/src/app/features/backlogs/burndown-chart.component.ts +++ b/frontend/src/app/features/backlogs/burndown-chart.component.ts @@ -27,7 +27,7 @@ //++ import { JsonPipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Signal, computed, inject, input } from '@angular/core'; import { ChartData, ChartOptions } from 'chart.js'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; @@ -60,7 +60,7 @@ export class BurndownChartComponent { .reduce((a, b) => Math.max(a, b), 0); }); - readonly lineChartOptions = computed>(() => ({ + readonly lineChartOptions:Signal> = computed>(() => ({ scales: { x: { title: { diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts index c3007d8e73c..0e8a201667b 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts @@ -29,6 +29,7 @@ import { ChangeDetectionStrategy, Component, + Signal, computed, input, } from '@angular/core'; @@ -51,7 +52,7 @@ export class ActualCostsComponent { readonly barChartData = computed>(() => JSON.parse(this.chartData()) as ChartData<'bar'>); - readonly barChartOptions = computed['options']>(() => ({ + readonly barChartOptions:Signal['options']> = computed['options']>(() => ({ font: chartFont, aspectRatio: 1.5, scales: { diff --git a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts index fd705283ae4..3b385d2863d 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts @@ -29,6 +29,7 @@ import { ChangeDetectionStrategy, Component, + Signal, computed, input, } from '@angular/core'; @@ -50,7 +51,7 @@ export class BudgetByCostTypeComponent { readonly pieChartData = computed>(() => JSON.parse(this.chartData()) as ChartData<'pie'>); - readonly pieChartOptions = computed['options']>(() => ({ + readonly pieChartOptions:Signal['options']> = computed['options']>(() => ({ font: chartFont, plugins: { ...chartLegend, From 5511a831ee67be6b0546a1cdc159764881d05a06 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 11 Feb 2026 20:25:07 -0300 Subject: [PATCH 06/21] Fix chart data serialisation in ActualCosts widget - Format month labels as ISO 8601 date strings (`to_date.iso8601`) instead of full timestamps, avoiding timezone-offset rendering bugs in Chart.js (UTC midnight parsed as prior day in negative-offset browsers) - Call `.to_f` on all dataset values so `BigDecimal` costs serialise as JSON numbers rather than strings Co-Authored-By: Claude Sonnet 4.5 --- modules/costs/app/components/costs/widgets/actual_costs.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/costs/app/components/costs/widgets/actual_costs.rb b/modules/costs/app/components/costs/widgets/actual_costs.rb index e2b491a5a8e..1286e9a85df 100644 --- a/modules/costs/app/components/costs/widgets/actual_costs.rb +++ b/modules/costs/app/components/costs/widgets/actual_costs.rb @@ -48,7 +48,7 @@ module Costs end def chart_labels - months + months.map { |month| month.to_date.iso8601 } end def chart_datasets @@ -66,7 +66,7 @@ module Costs def labor_dataset { label: t(:caption_labor), - data: months.map { |month| spent_labor_by_month.fetch(month, 0.0) } + data: months.map { |month| spent_labor_by_month.fetch(month, 0).to_f } } end @@ -74,7 +74,7 @@ module Costs cost_type_names.map do |cost_type_name| { label: cost_type_name, - data: months.map { |month| spent_material_by_month_and_type.fetch([month, cost_type_name], 0.0) } + data: months.map { |month| spent_material_by_month_and_type.fetch([month, cost_type_name], 0).to_f } } end end From 8c3536cf2be08fb0ff6b479a2124618cb707026c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 11 Feb 2026 20:34:22 -0300 Subject: [PATCH 07/21] Filter zero-value datasets from chart widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ActualCosts`: `labor_dataset` and each material dataset now return `nil` when all values are zero; `chart_datasets` compacts the result. Prevents an all-zero Labor bar from cluttering the chart when time entries exist but have no associated rates. `BudgetByCostType`: extract private `chart_entries` (memoized) that builds `{ label => value }` filtering non-positive values, keeping `chart_labels` and `chart_data` in sync. Prevents a Labor entry appearing in the legend when no labor budget items exist. Spec: add `view_hourly_rates` to `BudgetByCostType` test user — `LaborBudgetItem.visible` requires this permission, so without it `budgeted_labor` was silently 0, masking the previous incorrect always-include behaviour. Co-Authored-By: Claude Sonnet 4.5 --- .../budgets/widgets/budget_by_cost_type.rb | 13 ++++++++++-- .../widgets/budget_by_cost_type_spec.rb | 2 +- .../components/costs/widgets/actual_costs.rb | 20 +++++++++---------- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/modules/budgets/app/components/budgets/widgets/budget_by_cost_type.rb b/modules/budgets/app/components/budgets/widgets/budget_by_cost_type.rb index 03be70936f5..9a6837abbf7 100644 --- a/modules/budgets/app/components/budgets/widgets/budget_by_cost_type.rb +++ b/modules/budgets/app/components/budgets/widgets/budget_by_cost_type.rb @@ -48,11 +48,11 @@ module Budgets end def chart_labels - [t(:caption_labor)] + budgeted_material_by_type.keys + chart_entries.keys end def chart_data - [budgeted_labor] + budgeted_material_by_type.values.map(&:to_f) + chart_entries.values end private @@ -60,6 +60,15 @@ module Budgets def has_required_permissions? REQUIRED_PERMISSIONS.all? { |perm| current_user.allowed_in_project?(perm, project) } end + + def chart_entries + @chart_entries ||= {}.tap do |entries| + entries[t(:caption_labor)] = budgeted_labor.to_f if budgeted_labor.positive? + budgeted_material_by_type.each do |name, value| + entries[name] = value.to_f if value.positive? + end + end + end end end end diff --git a/modules/budgets/spec/components/budgets/widgets/budget_by_cost_type_spec.rb b/modules/budgets/spec/components/budgets/widgets/budget_by_cost_type_spec.rb index da8271a1473..119c577394f 100644 --- a/modules/budgets/spec/components/budgets/widgets/budget_by_cost_type_spec.rb +++ b/modules/budgets/spec/components/budgets/widgets/budget_by_cost_type_spec.rb @@ -37,7 +37,7 @@ RSpec.describe Budgets::Widgets::BudgetByCostType, type: :component do let(:project) { create(:project_with_types) } let(:current_user) do - create(:user, member_with_permissions: { project => %i[view_budgets view_cost_rates] }) + create(:user, member_with_permissions: { project => %i[view_budgets view_cost_rates view_hourly_rates] }) end subject(:rendered_component) { render_component(project, current_user:) } diff --git a/modules/costs/app/components/costs/widgets/actual_costs.rb b/modules/costs/app/components/costs/widgets/actual_costs.rb index 1286e9a85df..0435c4d2b06 100644 --- a/modules/costs/app/components/costs/widgets/actual_costs.rb +++ b/modules/costs/app/components/costs/widgets/actual_costs.rb @@ -54,7 +54,7 @@ module Costs def chart_datasets return [] unless months.any? - [labor_dataset] + material_datasets + [labor_dataset, *material_datasets].compact end private @@ -64,18 +64,18 @@ module Costs end def labor_dataset - { - label: t(:caption_labor), - data: months.map { |month| spent_labor_by_month.fetch(month, 0).to_f } - } + data = months.map { |month| spent_labor_by_month.fetch(month, 0).to_f } + return unless data.sum.positive? + + { label: t(:caption_labor), data: } end def material_datasets - cost_type_names.map do |cost_type_name| - { - label: cost_type_name, - data: months.map { |month| spent_material_by_month_and_type.fetch([month, cost_type_name], 0).to_f } - } + cost_type_names.filter_map do |cost_type_name| + data = months.map { |month| spent_material_by_month_and_type.fetch([month, cost_type_name], 0).to_f } + next unless data.sum.positive? + + { label: cost_type_name, data: } end end end From b761b78d1db81548bee643ea0ccbf70f3db2ea54 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 11 Feb 2026 21:18:40 -0300 Subject: [PATCH 08/21] Add no data Blankslate to chart components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds composable Blankslate primitive components in `shared/components/blankslate/`, plus two higher-level components: - `ErrorBlankSlateComponent` — for error states in grid widgets - `NoResultsComponent` (`op-no-results`) — "no data" empty state, intended to replace the legacy `no-results.component.ts` Wires `NoResultsComponent` into the Actual Costs, Budget by Cost Type, and Burndown Chart widgets (which gains a `hasChartData` guard). Adds `circle-slash` and `graph` to the Primer icon map. Co-Authored-By: Claude Sonnet 4.5 --- .../backlogs/burndown-chart.component.html | 16 ++-- .../backlogs/burndown-chart.component.ts | 7 +- .../blankslate/blankslate.component.html | 8 ++ .../blankslate/blankslate.component.ts | 74 +++++++++++++++++++ .../blankslate/no-results.component.html | 7 ++ .../blankslate/no-results.component.ts | 54 ++++++++++++++ .../overview/actual-costs.component.html | 10 ++- .../overview/actual-costs.component.ts | 4 +- .../budget-by-cost-type.component.html | 10 ++- .../overview/budget-by-cost-type.component.ts | 4 +- .../grids/openproject-grids.module.ts | 3 + .../error-blankslate.component.html | 10 +++ .../error-blankslate.component.ts | 58 +++++++++++++++ .../app/shared/components/icon/icon.module.ts | 3 + .../components/primer/dynamic-icon-map.ts | 4 +- modules/grids/config/locales/js-en.yml | 1 + 16 files changed, 257 insertions(+), 16 deletions(-) create mode 100644 frontend/src/app/shared/components/blankslate/blankslate.component.html create mode 100644 frontend/src/app/shared/components/blankslate/blankslate.component.ts create mode 100644 frontend/src/app/shared/components/blankslate/no-results.component.html create mode 100644 frontend/src/app/shared/components/blankslate/no-results.component.ts create mode 100644 frontend/src/app/shared/components/grids/widgets/error-blankslate/error-blankslate.component.html create mode 100644 frontend/src/app/shared/components/grids/widgets/error-blankslate/error-blankslate.component.ts diff --git a/frontend/src/app/features/backlogs/burndown-chart.component.html b/frontend/src/app/features/backlogs/burndown-chart.component.html index fb72c70a9ac..17638e1b340 100644 --- a/frontend/src/app/features/backlogs/burndown-chart.component.html +++ b/frontend/src/app/features/backlogs/burndown-chart.component.html @@ -1,9 +1,13 @@ -
- -
+@if (hasChartData()) { +
+ +
+} @else { + +} @if (isDevMode) {
diff --git a/frontend/src/app/features/backlogs/burndown-chart.component.ts b/frontend/src/app/features/backlogs/burndown-chart.component.ts index 185740556dc..83f433a74d5 100644 --- a/frontend/src/app/features/backlogs/burndown-chart.component.ts +++ b/frontend/src/app/features/backlogs/burndown-chart.component.ts @@ -30,6 +30,7 @@ import { JsonPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, Signal, computed, inject, input } from '@angular/core'; import { ChartData, ChartOptions } from 'chart.js'; import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { NoResultsComponent } from 'core-app/shared/components/blankslate/no-results.component'; import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; import { environment } from '../../../environments/environment'; @@ -39,7 +40,7 @@ const BURNDOWN_Y_SCALE_MIN = 25; @Component({ selector: 'op-burndown-chart', templateUrl: './burndown-chart.component.html', - imports: [BaseChartDirective, JsonPipe], + imports: [BaseChartDirective, JsonPipe, NoResultsComponent], providers: [provideCharts(withDefaultRegisterables(PrimerColorsPlugin))], changeDetection: ChangeDetectionStrategy.OnPush }) @@ -53,6 +54,10 @@ export class BurndownChartComponent { return data; }); + readonly hasChartData = computed(() => + this.lineChartData().datasets.some((ds) => ds.data.length > 0) + ); + readonly maxValue = computed(() => { return this.lineChartData().datasets .flatMap((dataset) => dataset.data) diff --git a/frontend/src/app/shared/components/blankslate/blankslate.component.html b/frontend/src/app/shared/components/blankslate/blankslate.component.html new file mode 100644 index 00000000000..a1c10149631 --- /dev/null +++ b/frontend/src/app/shared/components/blankslate/blankslate.component.html @@ -0,0 +1,8 @@ +
+
+ + + + +
+
diff --git a/frontend/src/app/shared/components/blankslate/blankslate.component.ts b/frontend/src/app/shared/components/blankslate/blankslate.component.ts new file mode 100644 index 00000000000..af49cfa9bc2 --- /dev/null +++ b/frontend/src/app/shared/components/blankslate/blankslate.component.ts @@ -0,0 +1,74 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +/* eslint-disable @angular-eslint/component-selector */ + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { DynamicIconDirective } from 'core-app/shared/components/primer/dynamic-icon.directive'; + +@Component({ + selector: 'blankslate-icon', + template: '', + imports: [DynamicIconDirective], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class BlankslateIconComponent { + readonly icon = input(); +} + +@Component({ + selector: 'blankslate-heading', + template: '

', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class BlankslateHeadingComponent { +} + +@Component({ + selector: 'blankslate-description', + template: '

', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class BlankslateDescriptionComponent { +} + +@Component({ + selector: 'blankslate-action', + template: '
', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class BlankslateActionComponent { +} + +@Component({ + selector: 'blankslate', + templateUrl: './blankslate.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class BlankslateComponent { +} diff --git a/frontend/src/app/shared/components/blankslate/no-results.component.html b/frontend/src/app/shared/components/blankslate/no-results.component.html new file mode 100644 index 00000000000..4df69ec259a --- /dev/null +++ b/frontend/src/app/shared/components/blankslate/no-results.component.html @@ -0,0 +1,7 @@ + + + {{ name() }} + @if (message()) { + {{ message() }} + } + diff --git a/frontend/src/app/shared/components/blankslate/no-results.component.ts b/frontend/src/app/shared/components/blankslate/no-results.component.ts new file mode 100644 index 00000000000..818811de535 --- /dev/null +++ b/frontend/src/app/shared/components/blankslate/no-results.component.ts @@ -0,0 +1,54 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { + BlankslateComponent, + BlankslateDescriptionComponent, + BlankslateHeadingComponent, + BlankslateIconComponent, +} from './blankslate.component'; + +@Component({ + selector: 'op-no-results', + templateUrl: './no-results.component.html', + imports: [ + BlankslateComponent, + BlankslateIconComponent, + BlankslateHeadingComponent, + BlankslateDescriptionComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NoResultsComponent { + private readonly i18n = inject(I18nService); + + readonly name = input(this.i18n.t('js.label_no_data')); + readonly message = input(); +} diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html index 51b07b8d23f..310eef6d3b3 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html @@ -1,3 +1,7 @@ -
- -
+@if (hasChartData()) { +
+ +
+} @else { + +} diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts index 0e8a201667b..58adab9bcfc 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts @@ -35,6 +35,7 @@ import { } from '@angular/core'; import { ChartConfiguration, ChartData } from 'chart.js'; import 'chartjs-adapter-luxon'; +import { NoResultsComponent } from 'core-app/shared/components/blankslate/no-results.component'; import { chartFont, chartLegend, renderChartTooltip } from 'core-app/shared/components/budget-graphs/chart.config'; import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; @@ -42,7 +43,7 @@ import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2 @Component({ selector: 'opce-actual-costs', templateUrl: './actual-costs.component.html', - imports: [BaseChartDirective], + imports: [BaseChartDirective, NoResultsComponent], changeDetection: ChangeDetectionStrategy.OnPush, providers: [provideCharts(withDefaultRegisterables(PrimerColorsPlugin))], }) @@ -51,6 +52,7 @@ export class ActualCostsComponent { readonly currency = input('EUR'); readonly barChartData = computed>(() => JSON.parse(this.chartData()) as ChartData<'bar'>); + readonly hasChartData = computed(() => this.barChartData().datasets.length > 0); readonly barChartOptions:Signal['options']> = computed['options']>(() => ({ font: chartFont, diff --git a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html index a6f02766f01..581d9194cca 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html +++ b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html @@ -1,3 +1,7 @@ -
- -
+@if (hasChartData()) { +
+ +
+} @else { + +} diff --git a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts index 3b385d2863d..b8506c21421 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts @@ -36,12 +36,13 @@ import { import { ChartConfiguration, ChartData } from 'chart.js'; import { chartFont, chartLegend, renderChartTooltip } from 'core-app/shared/components/budget-graphs/chart.config'; import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; +import { NoResultsComponent } from 'core-app/shared/components/blankslate/no-results.component'; import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; @Component({ selector: 'opce-budget-by-cost-type', templateUrl: './budget-by-cost-type.component.html', - imports: [BaseChartDirective], + imports: [BaseChartDirective, NoResultsComponent], changeDetection: ChangeDetectionStrategy.OnPush, providers: [provideCharts(withDefaultRegisterables(PrimerColorsPlugin))], }) @@ -50,6 +51,7 @@ export class BudgetByCostTypeComponent { readonly currency = input('EUR'); readonly pieChartData = computed>(() => JSON.parse(this.chartData()) as ChartData<'pie'>); + readonly hasChartData = computed(() => this.pieChartData().datasets[0].data.length > 0); readonly pieChartOptions:Signal['options']> = computed['options']>(() => ({ font: chartFont, diff --git a/frontend/src/app/shared/components/grids/openproject-grids.module.ts b/frontend/src/app/shared/components/grids/openproject-grids.module.ts index 70400628687..030add9d191 100644 --- a/frontend/src/app/shared/components/grids/openproject-grids.module.ts +++ b/frontend/src/app/shared/components/grids/openproject-grids.module.ts @@ -83,6 +83,7 @@ import { } from 'core-app/shared/components/grids/widgets/project-favorites/widget-project-favorites.component'; import { IconModule } from 'core-app/shared/components/icon/icon.module'; import { OpenprojectEnterpriseModule } from 'core-app/features/enterprise/openproject-enterprise.module'; +import { ErrorBlankSlateComponent } from './widgets/error-blankslate/error-blankslate.component'; @NgModule({ imports: [ @@ -104,6 +105,8 @@ import { OpenprojectEnterpriseModule } from 'core-app/features/enterprise/openpr // Support for inline editig fields OpenprojectFieldsModule, IconModule, + + ErrorBlankSlateComponent, ], providers: [ GridWidgetsService, diff --git a/frontend/src/app/shared/components/grids/widgets/error-blankslate/error-blankslate.component.html b/frontend/src/app/shared/components/grids/widgets/error-blankslate/error-blankslate.component.html new file mode 100644 index 00000000000..9ebbf8f2d84 --- /dev/null +++ b/frontend/src/app/shared/components/grids/widgets/error-blankslate/error-blankslate.component.html @@ -0,0 +1,10 @@ + + + {{ name() }} + {{ message() }} + @if (actionText()) { + + {{ actionText() }} + + } + diff --git a/frontend/src/app/shared/components/grids/widgets/error-blankslate/error-blankslate.component.ts b/frontend/src/app/shared/components/grids/widgets/error-blankslate/error-blankslate.component.ts new file mode 100644 index 00000000000..1881eec9ff6 --- /dev/null +++ b/frontend/src/app/shared/components/grids/widgets/error-blankslate/error-blankslate.component.ts @@ -0,0 +1,58 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +/* eslint-disable @angular-eslint/component-selector */ + +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { IconModule } from 'core-app/shared/components/icon/icon.module'; +import { BlankslateActionComponent, BlankslateComponent, BlankslateDescriptionComponent, BlankslateHeadingComponent, BlankslateIconComponent } from 'core-app/shared/components/blankslate/blankslate.component'; + +@Component({ + selector: 'error-blankslate', + templateUrl: './error-blankslate.component.html', + imports: [ + BlankslateComponent, + BlankslateIconComponent, + BlankslateHeadingComponent, + BlankslateDescriptionComponent, + BlankslateActionComponent, + IconModule + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ErrorBlankSlateComponent { + readonly name = input(); + readonly message = input(); + readonly actionText = input(); + readonly action = output(); + + onActionClick(event:Event) { + event.preventDefault?.(); + this.action.emit(); + } +} diff --git a/frontend/src/app/shared/components/icon/icon.module.ts b/frontend/src/app/shared/components/icon/icon.module.ts index fca623a5c1a..89139d5791e 100644 --- a/frontend/src/app/shared/components/icon/icon.module.ts +++ b/frontend/src/app/shared/components/icon/icon.module.ts @@ -98,6 +98,7 @@ import { ChevronDownIconComponent, VersionsIconComponent, BriefcaseIconComponent, + CircleSlashIconComponent } from '@openproject/octicons-angular'; @NgModule({ @@ -203,6 +204,7 @@ import { TriangleDownIconComponent, VersionsIconComponent, BriefcaseIconComponent, + CircleSlashIconComponent, ], declarations: [ OpIconComponent, @@ -309,6 +311,7 @@ import { TriangleDownIconComponent, VersionsIconComponent, BriefcaseIconComponent, + CircleSlashIconComponent, ], }) diff --git a/frontend/src/app/shared/components/primer/dynamic-icon-map.ts b/frontend/src/app/shared/components/primer/dynamic-icon-map.ts index da7f56b1bcc..5e3ba7dac96 100644 --- a/frontend/src/app/shared/components/primer/dynamic-icon-map.ts +++ b/frontend/src/app/shared/components/primer/dynamic-icon-map.ts @@ -26,10 +26,12 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { starIconData, SVGData, xIconData } from '@openproject/octicons-angular'; +import { circleSlashIconData, graphIconData, starIconData, SVGData, xIconData } from '@openproject/octicons-angular'; export const ICON_MAP:Record = { x: xIconData, star: starIconData, + 'circle-slash': circleSlashIconData, + graph: graphIconData // TODO add more icons }; diff --git a/modules/grids/config/locales/js-en.yml b/modules/grids/config/locales/js-en.yml index 0086ad71a69..db19d22b8d0 100644 --- a/modules/grids/config/locales/js-en.yml +++ b/modules/grids/config/locales/js-en.yml @@ -6,6 +6,7 @@ en: configure: 'Configure widget' widgets: missing_permission: "You don't have the necessary permissions to view this widget." + not_available: "This widget is currently unavailable." custom_text: title: 'Custom text' documents: From 81b9eec9f3d71a4609ae8882b37421cc14b538b2 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 11 Feb 2026 22:14:25 -0300 Subject: [PATCH 09/21] Harmonize tooltip format in budget chart widgets - Replace shared `renderChartTooltip` with per-chart-type factory functions `createBarTooltipRenderer` / `createPieTooltipRenderer` - Use distinct element IDs (`chartjs-tooltip-bar` / `chartjs-tooltip-pie`) so both widgets can coexist on the same page without duplicate IDs - Pie tooltip: color circle + bold cost type label on line 1 - Bar tooltip: bold date (text-nowrap) + color circle + bold cost type on line 1; cost type may wrap but date stays on a single line - Amount on line 2 using Primer `f4` class and `font-variant-numeric: tabular-nums` for consistent number alignment Co-Authored-By: Claude Sonnet 4.5 --- .../components/budget-graphs/chart.config.ts | 93 +++++++++++++++---- .../overview/actual-costs.component.ts | 20 +--- .../overview/budget-by-cost-type.component.ts | 11 +-- 3 files changed, 80 insertions(+), 44 deletions(-) diff --git a/frontend/src/app/shared/components/budget-graphs/chart.config.ts b/frontend/src/app/shared/components/budget-graphs/chart.config.ts index bc119656fde..cc01389be41 100644 --- a/frontend/src/app/shared/components/budget-graphs/chart.config.ts +++ b/frontend/src/app/shared/components/budget-graphs/chart.config.ts @@ -47,25 +47,23 @@ export const chartLegend:ChartOptions['plugins'] = { }, }; -export function renderChartTooltip(context:{ chart:{ canvas:HTMLCanvasElement }, tooltip:TooltipModel }) { - const tooltipModel = context.tooltip; - const popoverHtml = html` -
-
- ${tooltipModel.title} -
    - ${tooltipModel.body.map((item) => item.lines).map((body) => { - return html`
  • ${body}
  • `; - })} -
-
-
`; +type FormatCurrency = (value:number) => string; +interface TooltipContext { + chart:{ canvas:HTMLCanvasElement }; + tooltip:TooltipModel; +} + +function applyTooltipPosition( + context:TooltipContext, + popoverHtml:ReturnType, + tooltipId:string, +) { render(popoverHtml, document.body); - const tooltipEl = document.getElementById('chartjs-tooltip')!; + const tooltipEl = document.getElementById(tooltipId)!; - if (tooltipModel.opacity === 0) { + if (context.tooltip.opacity === 0) { tooltipEl.style.opacity = '0'; return; } @@ -74,7 +72,68 @@ export function renderChartTooltip(context:{ chart: tooltipEl.style.opacity = '1'; tooltipEl.style.position = 'absolute'; - tooltipEl.style.left = position.left + window.pageXOffset + tooltipModel.caretX + 'px'; - tooltipEl.style.top = position.top + window.pageYOffset + tooltipModel.caretY + 'px'; + tooltipEl.style.left = `${position.left + window.pageXOffset + context.tooltip.caretX}px`; + tooltipEl.style.top = `${position.top + window.pageYOffset + context.tooltip.caretY}px`; tooltipEl.style.pointerEvents = 'none'; } + +export function createBarTooltipRenderer(formatCurrency:FormatCurrency) { + return function(context:TooltipContext<'bar'>) { + const { tooltip } = context; + const popoverHtml = html` +
+
+
    + ${tooltip.dataPoints.map((dp, i) => { + const timestamp = dp.parsed.x; + const dateStr = timestamp != null + ? new Date(timestamp).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }) + : ''; + const label = dp.dataset.label ?? ''; + const value = dp.parsed.y ?? 0; + const color = tooltip.labelColors[i]?.backgroundColor as string; + return html` +
  • +
    + ${dateStr} + + ${label} +
    +
    ${formatCurrency(value)}
    +
  • `; + })} +
+
+
`; + + applyTooltipPosition(context, popoverHtml, 'chartjs-tooltip-bar'); + }; +} + +export function createPieTooltipRenderer(formatCurrency:FormatCurrency) { + return function(context:TooltipContext<'pie'>) { + const { tooltip } = context; + const popoverHtml = html` +
+
+
    + ${tooltip.dataPoints.map((dp, i) => { + const color = tooltip.labelColors[i]?.backgroundColor as string; + const label = dp.label ?? ''; + const value = dp.parsed; + return html` +
  • +
    + + ${label} +
    +
    ${formatCurrency(value)}
    +
  • `; + })} +
+
+
`; + + applyTooltipPosition(context, popoverHtml, 'chartjs-tooltip-pie'); + }; +} diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts index 58adab9bcfc..05b49f94eba 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts @@ -36,7 +36,7 @@ import { import { ChartConfiguration, ChartData } from 'chart.js'; import 'chartjs-adapter-luxon'; import { NoResultsComponent } from 'core-app/shared/components/blankslate/no-results.component'; -import { chartFont, chartLegend, renderChartTooltip } from 'core-app/shared/components/budget-graphs/chart.config'; +import { chartFont, chartLegend, createBarTooltipRenderer } from 'core-app/shared/components/budget-graphs/chart.config'; import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; @@ -76,23 +76,7 @@ export class ActualCostsComponent { ...chartLegend, tooltip: { enabled: false, - external: renderChartTooltip, - callbacks: { - title: (context) => { - const timestamp = context[0].parsed.x; - if (timestamp === null) return ''; - const date = new Date(timestamp); - return date.toLocaleDateString(undefined, { - month: 'short', - year: 'numeric', - }); - }, - label: (context) => { - const label = context.dataset.label ?? ''; - const value = context.raw as number; - return `${label}: ${this.formatCurrency(value)}`; - }, - }, + external: createBarTooltipRenderer(this.formatCurrency.bind(this)), }, }, })); diff --git a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts index b8506c21421..1cee8cfdf43 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts @@ -34,7 +34,7 @@ import { input, } from '@angular/core'; import { ChartConfiguration, ChartData } from 'chart.js'; -import { chartFont, chartLegend, renderChartTooltip } from 'core-app/shared/components/budget-graphs/chart.config'; +import { chartFont, chartLegend, createPieTooltipRenderer } from 'core-app/shared/components/budget-graphs/chart.config'; import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; import { NoResultsComponent } from 'core-app/shared/components/blankslate/no-results.component'; import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; @@ -59,14 +59,7 @@ export class BudgetByCostTypeComponent { ...chartLegend, tooltip: { enabled: false, - external: renderChartTooltip, - callbacks: { - label: (context) => { - const label = context.label ?? ''; - const value = context.parsed; - return `${label}: ${this.formatCurrency(value)}`; - }, - }, + external: createPieTooltipRenderer(this.formatCurrency.bind(this)), }, }, })); From f2ce28ea7b827d606787d5cae436d87b4631cb79 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 12 Feb 2026 00:15:27 -0300 Subject: [PATCH 10/21] Fix budget aggregation scoping and calculation Ensure dashboard totals match budgets index by filtering to budgeted entries and calculating costs correctly: - AggregatedCosts now filters to work packages with budgets assigned - AggregatedBudgets properly scopes to project after visibility checks - Material/labor costs calculated via `.costs` for nil amounts - Update specs to assign budgets to work packages Co-Authored-By: Claude Sonnet 4.5 --- .../app/models/budgets/aggregated_budgets.rb | 19 ++++++++------- .../budgets/widgets/budget_totals_spec.rb | 4 ++-- .../aggregated_budgets_with_spend_spec.rb | 23 +++++++++++++++++++ .../app/models/costs/aggregated_costs.rb | 18 +++++++++++++-- .../costs/widgets/actual_costs_spec.rb | 9 +++++--- .../models/costs/aggregated_costs_spec.rb | 21 +++++++++++------ 6 files changed, 72 insertions(+), 22 deletions(-) diff --git a/modules/budgets/app/models/budgets/aggregated_budgets.rb b/modules/budgets/app/models/budgets/aggregated_budgets.rb index 853628a5e7a..7668ca8b0dd 100644 --- a/modules/budgets/app/models/budgets/aggregated_budgets.rb +++ b/modules/budgets/app/models/budgets/aggregated_budgets.rb @@ -46,15 +46,19 @@ module Budgets end def budgeted_material - material_budget_items.sum(:amount) + material_budget_items.visible_costs(current_user).sum(&:costs) end def budgeted_material_by_type - material_budget_items_by_type.sum("material_budget_items.amount") + material_budget_items + .visible_costs(current_user) + .includes(:cost_type) + .group_by { |item| item.cost_type.name } + .transform_values { |items| items.sum(&:costs) } end def budgeted_labor - labor_budget_items.sum(:amount) + labor_budget_items.visible_costs(current_user).sum(&:costs) end def budgeted_total @@ -69,23 +73,22 @@ module Budgets def budgets Budget - .joins(:project) - .merge(applicable_projects) .visible(current_user) + .where(project_id: applicable_projects) end def material_budget_items MaterialBudgetItem .joins(budget: :project) - .merge(applicable_projects) .visible(current_user) + .where(budget: { project_id: applicable_projects }) end def material_budget_items_by_type CostType .left_joins(material_budget_items: { budget: :project }) - .merge(applicable_projects) .merge(MaterialBudgetItem.visible(current_user)) + .where(projects: { id: applicable_projects }) .group(:name) .order(name: :asc) end @@ -93,8 +96,8 @@ module Budgets def labor_budget_items LaborBudgetItem .joins(budget: :project) - .merge(applicable_projects) .visible(current_user) + .where(budget: { project_id: applicable_projects }) end end end diff --git a/modules/budgets/spec/components/budgets/widgets/budget_totals_spec.rb b/modules/budgets/spec/components/budgets/widgets/budget_totals_spec.rb index a66c1e7dfb8..3469946a1c9 100644 --- a/modules/budgets/spec/components/budgets/widgets/budget_totals_spec.rb +++ b/modules/budgets/spec/components/budgets/widgets/budget_totals_spec.rb @@ -74,7 +74,7 @@ RSpec.describe Budgets::Widgets::BudgetTotals, type: :component do end context "with budget and spending data" do - let(:work_package) { create(:work_package, project:) } + let(:work_package) { create(:work_package, project:, budget:) } let!(:budget) { create(:budget, project:, base_amount: BigDecimal("10000")) } let!(:hourly_rate) do create(:hourly_rate, user: current_user, project:, rate: 50.0, valid_from: 1.month.ago) @@ -100,7 +100,7 @@ RSpec.describe Budgets::Widgets::BudgetTotals, type: :component do end context "with overspending (negative remaining)" do - let(:work_package) { create(:work_package, project:) } + let(:work_package) { create(:work_package, project:, budget:) } let!(:budget) { create(:budget, project:, base_amount: BigDecimal("5000")) } let!(:hourly_rate) do create(:hourly_rate, user: current_user, project:, rate: 100.0, valid_from: 1.month.ago) diff --git a/modules/budgets/spec/models/budgets/aggregated_budgets_with_spend_spec.rb b/modules/budgets/spec/models/budgets/aggregated_budgets_with_spend_spec.rb index 1e0770e599a..ed78bf340c3 100644 --- a/modules/budgets/spec/models/budgets/aggregated_budgets_with_spend_spec.rb +++ b/modules/budgets/spec/models/budgets/aggregated_budgets_with_spend_spec.rb @@ -54,6 +54,7 @@ RSpec.describe Budgets::AggregatedBudgetsWithSpend do end context "with both material and labor spending" do + let!(:budget) { create(:budget, project:) } let!(:cost_type) { create(:cost_type) } let!(:cost_rate) do create(:cost_rate, @@ -80,6 +81,7 @@ RSpec.describe Budgets::AggregatedBudgetsWithSpend do end before do + work_package.update!(budget:) create(:hourly_rate, user:, project:, @@ -119,6 +121,10 @@ RSpec.describe Budgets::AggregatedBudgetsWithSpend do spent_on: Date.current) end + before do + work_package.update(budget:) + end + it "calculates the ratio of spent to budget" do expect(aggregated.spent_ratio).to eq(BigDecimal("0.5")) end @@ -143,6 +149,10 @@ RSpec.describe Budgets::AggregatedBudgetsWithSpend do spent_on: Date.current) end + before do + work_package.update(budget:) + end + it "returns a ratio greater than 1" do expect(aggregated.spent_ratio).to eq(BigDecimal("1.5")) end @@ -177,6 +187,10 @@ RSpec.describe Budgets::AggregatedBudgetsWithSpend do spent_on: Date.current) end + before do + work_package.update(budget:) + end + it "calculates remaining as budgeted_total - spent_total" do expect(aggregated.remaining).to eq(BigDecimal("7000")) end @@ -201,6 +215,10 @@ RSpec.describe Budgets::AggregatedBudgetsWithSpend do spent_on: Date.current) end + before do + work_package.update(budget:) + end + it "returns 0" do expect(aggregated.remaining).to eq(BigDecimal("0")) end @@ -225,6 +243,10 @@ RSpec.describe Budgets::AggregatedBudgetsWithSpend do spent_on: Date.current) end + before do + work_package.update(budget:) + end + it "returns negative value" do expect(aggregated.remaining).to eq(BigDecimal("-2000")) end @@ -266,6 +288,7 @@ RSpec.describe Budgets::AggregatedBudgetsWithSpend do end before do + work_package.update!(budget:) create(:hourly_rate, user:, project:, diff --git a/modules/costs/app/models/costs/aggregated_costs.rb b/modules/costs/app/models/costs/aggregated_costs.rb index fe6479d67b5..395de221c0e 100644 --- a/modules/costs/app/models/costs/aggregated_costs.rb +++ b/modules/costs/app/models/costs/aggregated_costs.rb @@ -75,7 +75,11 @@ module Costs private def cost_entries - scope = CostEntry.joins(:project).merge(applicable_projects).visible_costs(current_user) + scope = CostEntry + .joins(:project) + .merge(applicable_projects) + .on_work_packages(budgeted_work_packages) + .visible_costs(current_user) scope = scope.where(spent_on: @date_range) if @date_range scope end @@ -91,7 +95,11 @@ module Costs end def time_entries - scope = TimeEntry.joins(:project).merge(applicable_projects).visible_costs(current_user) + scope = TimeEntry + .joins(:project) + .merge(applicable_projects) + .on_work_packages(budgeted_work_packages) + .visible_costs(current_user) scope = scope.where(spent_on: @date_range) if @date_range scope end @@ -99,5 +107,11 @@ module Costs def time_entries_by_month time_entries.group("date_trunc('month', time_entries.spent_on)") end + + def budgeted_work_packages + WorkPackage + .where(project_id: applicable_projects) + .where.associated(:budget) + end end end diff --git a/modules/costs/spec/components/costs/widgets/actual_costs_spec.rb b/modules/costs/spec/components/costs/widgets/actual_costs_spec.rb index 03f2176e01e..cff0015e753 100644 --- a/modules/costs/spec/components/costs/widgets/actual_costs_spec.rb +++ b/modules/costs/spec/components/costs/widgets/actual_costs_spec.rb @@ -47,7 +47,8 @@ RSpec.describe Costs::Widgets::ActualCosts, type: :component do subject(:rendered_component) { render_component(project, current_user:) } context "with spending data" do - let(:work_package) { create(:work_package, project: project) } + let!(:budget) { create(:budget, project:) } + let(:work_package) { create(:work_package, project:, budget:) } let!(:hourly_rate) do create(:hourly_rate, user: current_user, @@ -108,7 +109,8 @@ RSpec.describe Costs::Widgets::ActualCosts, type: :component do end context "with material cost entries" do - let(:work_package) { create(:work_package, project: project) } + let!(:budget) { create(:budget, project:) } + let(:work_package) { create(:work_package, project:, budget:) } let(:cost_type) { create(:cost_type, name: "Development") } let!(:cost_rate) do create(:cost_rate, @@ -138,7 +140,8 @@ RSpec.describe Costs::Widgets::ActualCosts, type: :component do end context "with spending data only from a prior year" do - let(:work_package) { create(:work_package, project: project) } + let!(:budget) { create(:budget, project:) } + let(:work_package) { create(:work_package, project:, budget:) } let!(:hourly_rate) do create(:hourly_rate, user: current_user, diff --git a/modules/costs/spec/models/costs/aggregated_costs_spec.rb b/modules/costs/spec/models/costs/aggregated_costs_spec.rb index 43813f6120b..ab74c874beb 100644 --- a/modules/costs/spec/models/costs/aggregated_costs_spec.rb +++ b/modules/costs/spec/models/costs/aggregated_costs_spec.rb @@ -38,9 +38,11 @@ RSpec.describe Costs::AggregatedCosts do view_cost_rates view_time_entries view_hourly_rates - view_own_hourly_rate] }) + view_own_hourly_rate + view_budgets] }) end - shared_let(:work_package) { create(:work_package, project: project) } + shared_let(:budget) { create(:budget, project:) } + shared_let(:work_package) { create(:work_package, project:, budget:) } subject(:aggregated) { described_class.new(project:, current_user: user) } @@ -705,7 +707,8 @@ RSpec.describe Costs::AggregatedCosts do permissions: %i[view_cost_entries view_cost_rates view_time_entries - view_hourly_rates])]) + view_hourly_rates + view_budgets])]) # Create memberships for user in child projects create(:member, project: child_project_one, @@ -714,7 +717,8 @@ RSpec.describe Costs::AggregatedCosts do permissions: %i[view_cost_entries view_cost_rates view_time_entries - view_hourly_rates])]) + view_hourly_rates + view_budgets])]) create(:member, project: child_project_two, user:, @@ -722,12 +726,15 @@ RSpec.describe Costs::AggregatedCosts do permissions: %i[view_cost_entries view_cost_rates view_time_entries - view_hourly_rates])]) + view_hourly_rates + view_budgets])]) end context "with spending in child projects" do - let!(:wp1) { create(:work_package, project: child_project_one) } - let!(:wp2) { create(:work_package, project: child_project_two) } + let!(:budget1) { create(:budget, project: child_project_one) } + let!(:budget2) { create(:budget, project: child_project_two) } + let!(:wp1) { create(:work_package, project: child_project_one, budget: budget1) } + let!(:wp2) { create(:work_package, project: child_project_two, budget: budget2) } let!(:cost_type) { create(:cost_type) } let!(:cost_rate) do create(:cost_rate, From 9a1c38a418594533794fe610124280bebc4d4f7a Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Feb 2026 15:05:18 -0300 Subject: [PATCH 11/21] Require budgets module for ActualCosts widget Co-Authored-By: Claude Opus 4.6 --- modules/costs/app/components/costs/widgets/actual_costs.rb | 6 +++++- .../spec/components/costs/widgets/actual_costs_spec.rb | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/modules/costs/app/components/costs/widgets/actual_costs.rb b/modules/costs/app/components/costs/widgets/actual_costs.rb index 0435c4d2b06..551d7003185 100644 --- a/modules/costs/app/components/costs/widgets/actual_costs.rb +++ b/modules/costs/app/components/costs/widgets/actual_costs.rb @@ -31,7 +31,7 @@ module Costs module Widgets class ActualCosts < Costs::WidgetComponent - REQUIRED_PERMISSIONS = %i[view_cost_entries view_cost_rates view_time_entries view_hourly_rates].freeze + REQUIRED_PERMISSIONS = %i[view_budgets view_cost_entries view_cost_rates view_time_entries view_hourly_rates].freeze delegate :has_spending?, :months, :cost_type_names, :spent_labor_by_month, :spent_material_by_month_and_type, @@ -43,6 +43,10 @@ module Costs @aggregated_costs = Costs::AggregatedCosts.new(project:, current_user:, date_range: Date.current.all_year) end + def render? + super && project.module_enabled?(:budgets) + end + def title t(".title") end diff --git a/modules/costs/spec/components/costs/widgets/actual_costs_spec.rb b/modules/costs/spec/components/costs/widgets/actual_costs_spec.rb index cff0015e753..7ba5824c48b 100644 --- a/modules/costs/spec/components/costs/widgets/actual_costs_spec.rb +++ b/modules/costs/spec/components/costs/widgets/actual_costs_spec.rb @@ -38,7 +38,8 @@ RSpec.describe Costs::Widgets::ActualCosts, type: :component do let(:project) { create(:project_with_types) } let(:current_user) do create(:user, - member_with_permissions: { project => %i[view_cost_entries + member_with_permissions: { project => %i[view_budgets + view_cost_entries view_cost_rates view_time_entries view_hourly_rates] }) From 4964236bac13cb3bfe053c5439344603b87b8f3b Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Feb 2026 15:05:22 -0300 Subject: [PATCH 12/21] Add permission checks to BudgetTotals widget Co-Authored-By: Claude Opus 4.6 --- .../budgets/app/components/budgets/widgets/budget_totals.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/budgets/app/components/budgets/widgets/budget_totals.rb b/modules/budgets/app/components/budgets/widgets/budget_totals.rb index 79c0896726e..f4fc8b0d8af 100644 --- a/modules/budgets/app/components/budgets/widgets/budget_totals.rb +++ b/modules/budgets/app/components/budgets/widgets/budget_totals.rb @@ -31,6 +31,8 @@ module Budgets module Widgets class BudgetTotals < Budgets::WidgetComponent + REQUIRED_PERMISSIONS = %i[view_budgets view_cost_entries view_cost_rates view_time_entries view_hourly_rates].freeze + delegate :has_budgets?, :budgeted_total, :spent_total, :spent_ratio, :remaining, to: :@aggregated_budgets_with_spend @@ -67,6 +69,10 @@ module Budgets private + def has_required_permissions? + REQUIRED_PERMISSIONS.all? { |perm| current_user.allowed_in_project?(perm, project) } + end + def render_currency(value) color = value.negative? ? :danger : :default render(Primer::Beta::Truncate.new(font_weight: :bold, font_size: 1, color:)) do From ef0e8f47662eca208dcbc1bc4cf29fa20bca4ecc Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Feb 2026 15:40:23 -0300 Subject: [PATCH 13/21] Fix project attribute widgets spacing --- .../grids/app/components/grids/project_attribute_widgets.sass | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/grids/app/components/grids/project_attribute_widgets.sass b/modules/grids/app/components/grids/project_attribute_widgets.sass index 433bd042389..b697aff86ce 100644 --- a/modules/grids/app/components/grids/project_attribute_widgets.sass +++ b/modules/grids/app/components/grids/project_attribute_widgets.sass @@ -2,3 +2,4 @@ display: flex flex-wrap: wrap flex-basis: 100% + gap: var(--stack-gap-normal) From 405e8da728623670ff0a59274a9df0d8964ad897 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Feb 2026 16:01:35 -0300 Subject: [PATCH 14/21] Customize blankslate text in chart widgets Co-Authored-By: Claude Opus 4.6 --- .../components/blankslate/no-results.component.html | 2 +- .../components/blankslate/no-results.component.ts | 2 +- .../overview/actual-costs.component.html | 2 +- .../budget-graphs/overview/actual-costs.component.ts | 11 +++++++++++ .../overview/budget-by-cost-type.component.html | 2 +- .../overview/budget-by-cost-type.component.ts | 11 +++++++++++ modules/budgets/config/locales/js-en.yml | 6 ++++++ modules/costs/config/locales/js-en.yml | 6 ++++++ 8 files changed, 38 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/shared/components/blankslate/no-results.component.html b/frontend/src/app/shared/components/blankslate/no-results.component.html index 4df69ec259a..c713a66b4d1 100644 --- a/frontend/src/app/shared/components/blankslate/no-results.component.html +++ b/frontend/src/app/shared/components/blankslate/no-results.component.html @@ -1,6 +1,6 @@ - {{ name() }} + {{ title() }} @if (message()) { {{ message() }} } diff --git a/frontend/src/app/shared/components/blankslate/no-results.component.ts b/frontend/src/app/shared/components/blankslate/no-results.component.ts index 818811de535..313967bcdd4 100644 --- a/frontend/src/app/shared/components/blankslate/no-results.component.ts +++ b/frontend/src/app/shared/components/blankslate/no-results.component.ts @@ -49,6 +49,6 @@ import { export class NoResultsComponent { private readonly i18n = inject(I18nService); - readonly name = input(this.i18n.t('js.label_no_data')); + readonly title = input(this.i18n.t('js.label_no_data')); readonly message = input(); } diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html index 310eef6d3b3..55648ee139c 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html @@ -3,5 +3,5 @@ } @else { - + } diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts index 05b49f94eba..8bad9505572 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts @@ -31,10 +31,12 @@ import { Component, Signal, computed, + inject, input, } from '@angular/core'; import { ChartConfiguration, ChartData } from 'chart.js'; import 'chartjs-adapter-luxon'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; import { NoResultsComponent } from 'core-app/shared/components/blankslate/no-results.component'; import { chartFont, chartLegend, createBarTooltipRenderer } from 'core-app/shared/components/budget-graphs/chart.config'; import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; @@ -48,9 +50,18 @@ import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2 providers: [provideCharts(withDefaultRegisterables(PrimerColorsPlugin))], }) export class ActualCostsComponent { + private readonly i18n = inject(I18nService); + readonly chartData = input.required(); readonly currency = input('EUR'); + readonly text = { + noResults: { + title: this.i18n.t('js.costs.widgets.actual_costs.blankslate.title'), + description: this.i18n.t('js.costs.widgets.actual_costs.blankslate.description'), + }, + }; + readonly barChartData = computed>(() => JSON.parse(this.chartData()) as ChartData<'bar'>); readonly hasChartData = computed(() => this.barChartData().datasets.length > 0); diff --git a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html index 581d9194cca..c87b2a8656a 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html +++ b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html @@ -3,5 +3,5 @@ } @else { - + } diff --git a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts index 1cee8cfdf43..821f02aa5dc 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts @@ -31,9 +31,11 @@ import { Component, Signal, computed, + inject, input, } from '@angular/core'; import { ChartConfiguration, ChartData } from 'chart.js'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; import { chartFont, chartLegend, createPieTooltipRenderer } from 'core-app/shared/components/budget-graphs/chart.config'; import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; import { NoResultsComponent } from 'core-app/shared/components/blankslate/no-results.component'; @@ -47,9 +49,18 @@ import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2 providers: [provideCharts(withDefaultRegisterables(PrimerColorsPlugin))], }) export class BudgetByCostTypeComponent { + private readonly i18n = inject(I18nService); + readonly chartData = input.required(); readonly currency = input('EUR'); + readonly text = { + noResults: { + title: this.i18n.t('js.budgets.widgets.budget_by_cost_type.blankslate.title'), + description: this.i18n.t('js.budgets.widgets.budget_by_cost_type.blankslate.description'), + }, + }; + readonly pieChartData = computed>(() => JSON.parse(this.chartData()) as ChartData<'pie'>); readonly hasChartData = computed(() => this.pieChartData().datasets[0].data.length > 0); diff --git a/modules/budgets/config/locales/js-en.yml b/modules/budgets/config/locales/js-en.yml index d2851eed35f..285c2d1b20f 100644 --- a/modules/budgets/config/locales/js-en.yml +++ b/modules/budgets/config/locales/js-en.yml @@ -28,6 +28,12 @@ en: js: + budgets: + widgets: + budget_by_cost_type: + blankslate: + title: "No budget data" + description: "Add planned unit and labor costs to this project to start tracking the budget" work_packages: properties: costObject: "Budget" diff --git a/modules/costs/config/locales/js-en.yml b/modules/costs/config/locales/js-en.yml index 4c60e29a0d4..932f4312fd5 100644 --- a/modules/costs/config/locales/js-en.yml +++ b/modules/costs/config/locales/js-en.yml @@ -28,6 +28,12 @@ en: js: + costs: + widgets: + actual_costs: + blankslate: + title: "No data for current year" + description: "Log spent time and costs for work packages to start tracking actual costs" text_are_you_sure: "Are you sure?" myTimeTracking: noSpecificTime: "No specific time" From 8401e276833c5ef265524e011b8782c1abb16d97 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Feb 2026 16:45:00 -0300 Subject: [PATCH 15/21] Fix missing comma in graphIconData Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- frontend/src/app/shared/components/primer/dynamic-icon-map.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/shared/components/primer/dynamic-icon-map.ts b/frontend/src/app/shared/components/primer/dynamic-icon-map.ts index 5e3ba7dac96..78255a8dae2 100644 --- a/frontend/src/app/shared/components/primer/dynamic-icon-map.ts +++ b/frontend/src/app/shared/components/primer/dynamic-icon-map.ts @@ -32,6 +32,6 @@ export const ICON_MAP:Record = { x: xIconData, star: starIconData, 'circle-slash': circleSlashIconData, - graph: graphIconData + graph: graphIconData, // TODO add more icons }; From 5d16fdd6e99df4d548d7eab55bdd86cdcca0db3f Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Feb 2026 16:31:47 -0300 Subject: [PATCH 16/21] Refactor chart tooltip rendering Extract shared `renderColorDot`, `renderTooltipItem`, and `renderTooltipPopover` helpers used by both bar and pie tooltips. Bar tooltip header now uses inline elements so the full line (date + color dot + label) wraps naturally. Color dot uses `vertical-align: baseline` for correct vertical centering against capital letters, with `margin-right: 4px` and `margin-right: 8px` on the date for clear separation. Co-Authored-By: Claude Sonnet 4.5 --- .../components/budget-graphs/chart.config.ts | 98 +++++++++---------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/frontend/src/app/shared/components/budget-graphs/chart.config.ts b/frontend/src/app/shared/components/budget-graphs/chart.config.ts index cc01389be41..76cc9146846 100644 --- a/frontend/src/app/shared/components/budget-graphs/chart.config.ts +++ b/frontend/src/app/shared/components/budget-graphs/chart.config.ts @@ -77,63 +77,63 @@ function applyTooltipPosition( tooltipEl.style.pointerEvents = 'none'; } +function renderColorDot(color:string) { + return html``; +} + +function renderTooltipItem( + color:string, + label:string, + formattedValue:string, + dateStr?:string, +):ReturnType { + const header = dateStr + ? html`
${dateStr}${renderColorDot(color)}${label}
` + : html`
${renderColorDot(color)}${label}
`; + return html` +
  • + ${header} +
    ${formattedValue}
    +
  • `; +} + +function renderTooltipPopover(tooltipId:string, items:ReturnType[]):ReturnType { + return html` +
    +
    +
      + ${items} +
    +
    +
    `; +} + export function createBarTooltipRenderer(formatCurrency:FormatCurrency) { return function(context:TooltipContext<'bar'>) { const { tooltip } = context; - const popoverHtml = html` -
    -
    -
      - ${tooltip.dataPoints.map((dp, i) => { - const timestamp = dp.parsed.x; - const dateStr = timestamp != null - ? new Date(timestamp).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }) - : ''; - const label = dp.dataset.label ?? ''; - const value = dp.parsed.y ?? 0; - const color = tooltip.labelColors[i]?.backgroundColor as string; - return html` -
    • -
      - ${dateStr} - - ${label} -
      -
      ${formatCurrency(value)}
      -
    • `; - })} -
    -
    -
    `; - - applyTooltipPosition(context, popoverHtml, 'chartjs-tooltip-bar'); + const items = tooltip.dataPoints.map((dp, i) => { + const timestamp = dp.parsed.x; + const dateStr = timestamp != null + ? new Date(timestamp).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }) + : undefined; + const label = dp.dataset.label ?? ''; + const value = dp.parsed.y ?? 0; + const color = tooltip.labelColors[i]?.backgroundColor as string; + return renderTooltipItem(color, label, formatCurrency(value), dateStr); + }); + applyTooltipPosition(context, renderTooltipPopover('chartjs-tooltip-bar', items), 'chartjs-tooltip-bar'); }; } export function createPieTooltipRenderer(formatCurrency:FormatCurrency) { return function(context:TooltipContext<'pie'>) { const { tooltip } = context; - const popoverHtml = html` -
    -
    -
      - ${tooltip.dataPoints.map((dp, i) => { - const color = tooltip.labelColors[i]?.backgroundColor as string; - const label = dp.label ?? ''; - const value = dp.parsed; - return html` -
    • -
      - - ${label} -
      -
      ${formatCurrency(value)}
      -
    • `; - })} -
    -
    -
    `; - - applyTooltipPosition(context, popoverHtml, 'chartjs-tooltip-pie'); + const items = tooltip.dataPoints.map((dp, i) => { + const color = tooltip.labelColors[i]?.backgroundColor as string; + const label = dp.label ?? ''; + const value = dp.parsed; + return renderTooltipItem(color, label, formatCurrency(value)); + }); + applyTooltipPosition(context, renderTooltipPopover('chartjs-tooltip-pie', items), 'chartjs-tooltip-pie'); }; } From 9c93f4ee55ed54c683fc47aaa1332e1a90b663a2 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Feb 2026 16:39:25 -0300 Subject: [PATCH 17/21] Smooth chart tooltips with CSS transform Switch from `position: absolute` with `left`/`top` to `position: fixed` with `transform: translate(x,y)`. This moves positioning to the compositor thread (no layout reflow) and enables CSS transitions. On first appearance the tooltip snaps to the correct position before fading in (via a forced reflow with `transition: none`) to avoid sliding in from (0,0). When already visible, `transform` animates smoothly between data points. Co-Authored-By: Claude Sonnet 4.5 --- .../components/budget-graphs/chart.config.ts | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/shared/components/budget-graphs/chart.config.ts b/frontend/src/app/shared/components/budget-graphs/chart.config.ts index 76cc9146846..03f38db0736 100644 --- a/frontend/src/app/shared/components/budget-graphs/chart.config.ts +++ b/frontend/src/app/shared/components/budget-graphs/chart.config.ts @@ -68,13 +68,23 @@ function applyTooltipPosition( return; } - const position = context.chart.canvas.getBoundingClientRect(); + const { left, top } = context.chart.canvas.getBoundingClientRect(); + const x = Math.round(left + context.tooltip.caretX); + const y = Math.round(top + context.tooltip.caretY); + + const wasHidden = !tooltipEl.style.opacity || tooltipEl.style.opacity === '0'; + if (wasHidden) { + // Snap to position before fading in (avoids sliding from initial 0,0) + tooltipEl.style.transition = 'none'; + tooltipEl.style.transform = `translate(${x}px, ${y}px)`; + void tooltipEl.offsetHeight; // force reflow so transform is committed + tooltipEl.style.transition = 'transform 0.1s ease, opacity 0.15s ease'; + } else { + tooltipEl.style.transition = 'transform 0.1s ease, opacity 0.15s ease'; + tooltipEl.style.transform = `translate(${x}px, ${y}px)`; + } tooltipEl.style.opacity = '1'; - tooltipEl.style.position = 'absolute'; - tooltipEl.style.left = `${position.left + window.pageXOffset + context.tooltip.caretX}px`; - tooltipEl.style.top = `${position.top + window.pageYOffset + context.tooltip.caretY}px`; - tooltipEl.style.pointerEvents = 'none'; } function renderColorDot(color:string) { @@ -99,7 +109,7 @@ function renderTooltipItem( function renderTooltipPopover(tooltipId:string, items:ReturnType[]):ReturnType { return html` -
    +
      ${items} From 9363923c181bf91b373316a75e0085eaea79efad Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Feb 2026 16:54:30 -0300 Subject: [PATCH 18/21] Deterministic colors for cost type chart labels Add `labelBased` option to `PrimerColorsPlugin`. When enabled, a collision-free color map is built from all labels in the chart: each label's preferred palette index is `hash(label) % 16`, with linear probing (ascending-preferred-index order, alphabetical tie-break) to resolve collisions. This guarantees every label in a chart gets a unique color, and the same label name always maps to the same color when both charts share the same cost types. Co-Authored-By: Claude Sonnet 4.5 --- .../overview/actual-costs.component.ts | 1 + .../overview/budget-by-cost-type.component.ts | 1 + .../plugin.primer-colors.ts | 64 ++++++++++++++++++- 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts index 8bad9505572..07c0ea78cdf 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts @@ -85,6 +85,7 @@ export class ActualCostsComponent { }, plugins: { ...chartLegend, + 'primer-colors': { labelBased: true }, tooltip: { enabled: false, external: createBarTooltipRenderer(this.formatCurrency.bind(this)), diff --git a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts index 821f02aa5dc..961f94ed0db 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts @@ -68,6 +68,7 @@ export class BudgetByCostTypeComponent { font: chartFont, plugins: { ...chartLegend, + 'primer-colors': { labelBased: true }, tooltip: { enabled: false, external: createPieTooltipRenderer(this.formatCurrency.bind(this)), diff --git a/frontend/src/app/shared/components/work-package-graphs/plugin.primer-colors.ts b/frontend/src/app/shared/components/work-package-graphs/plugin.primer-colors.ts index 6f536b500b8..7638bd28698 100644 --- a/frontend/src/app/shared/components/work-package-graphs/plugin.primer-colors.ts +++ b/frontend/src/app/shared/components/work-package-graphs/plugin.primer-colors.ts @@ -36,6 +36,14 @@ import { export interface PrimerColorsPluginOptions { enabled?:boolean; + labelBased?:boolean; +} + +declare module 'chart.js' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface PluginOptionsByType { + 'primer-colors':PrimerColorsPluginOptions; + } } const PRIMER_COLORS = [ @@ -125,6 +133,50 @@ function getColorizer() { }; } +// djb2-style XOR hash → stable uint32 for a given label string +function hashLabel(label:string):number { + let hash = 5381; + for (let i = 0; i < label.length; i++) { + hash = ((hash << 5) + hash) ^ label.charCodeAt(i); + hash = hash >>> 0; // keep as uint32 + } + return hash; +} + +// Build a collision-free label→palette-index map. +// Each label's preferred slot is hash(label) % paletteSize. +// If that slot is already claimed, linear-probe to the next free one. +// Processing order: ascending preferred index, alphabetical tie-break — so the +// same algorithm produces identical results for any subset of the same labels. +function buildLabelColorMap(labels:string[]):Map { + const paletteSize = PRIMER_COLORS.length; + const items = labels + .map((label) => ({ label, preferred: hashLabel(label) % paletteSize })) + .sort((a, b) => a.preferred - b.preferred || a.label.localeCompare(b.label)); + + const used = new Set(); + const map = new Map(); + + for (const { label, preferred } of items) { + let slot = preferred; + while (used.has(slot)) { + slot = (slot + 1) % paletteSize; + } + used.add(slot); + map.set(label, slot); + } + + return map; +} + +// Assign colors to a single-dataset chart where each data point has its own label +function assignColorsByLabel(dataset:ChartDataset, labels:string[]):void { + const colorMap = buildLabelColorMap(labels); + dataset.backgroundColor = dataset.data.map((_, i) => getMutedColor(colorMap.get(labels[i] ?? '') ?? 0)); + dataset.borderColor = dataset.data.map((_, i) => getEmphasisColor(colorMap.get(labels[i] ?? '') ?? 0)); + dataset.borderWidth = 1; +} + const plugin:Plugin = { id: 'primer-colors', defaults: { enabled: true }, @@ -135,7 +187,17 @@ const plugin:Plugin = { } const { data: { datasets } } = chart.config; - if (datasets.length === 1) { + if (options.labelBased) { + if (datasets.length === 1) { + assignColorsByLabel(datasets[0], (chart.data.labels ?? []) as string[]); + } else { + const labels = datasets.map((d) => d.label ?? ''); + const colorMap = buildLabelColorMap(labels); + datasets.forEach((dataset:ChartDataset) => { + colorizeMultiDataset(dataset, colorMap.get(dataset.label ?? '') ?? 0); + }); + } + } else if (datasets.length === 1) { const colorizer = getColorizer(); datasets.forEach(colorizer); } else { From 62128e7ff9364453d3e1dfd7d20973640471c81c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Feb 2026 17:24:34 -0300 Subject: [PATCH 19/21] Pad actual costs chart x-axis with full year MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always generate every calendar month in `date_range` for `AggregatedCosts#months` using idiomatic `Range#step(1.month)`. Normalize `spent_labor_by_month` and `spent_material_by_month_and_type` keys to `Date` via `transform_keys` — without this, `date_trunc` returns `Time` objects that never match the `Date` keys from `months`, leaving `chart_datasets` empty. Update `aggregated_costs_spec` to assert `Date`-typed keys and cover the full-range padding behaviour. Co-Authored-By: Claude Sonnet 4.5 --- .../app/models/costs/aggregated_costs.rb | 21 +++++++++++--- .../models/costs/aggregated_costs_spec.rb | 28 +++++++++++++++---- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/modules/costs/app/models/costs/aggregated_costs.rb b/modules/costs/app/models/costs/aggregated_costs.rb index 395de221c0e..db5e6dca6bc 100644 --- a/modules/costs/app/models/costs/aggregated_costs.rb +++ b/modules/costs/app/models/costs/aggregated_costs.rb @@ -39,9 +39,15 @@ module Costs end def months - labor_months = spent_labor_by_month.keys - material_months = spent_material_by_month_and_type.keys.map(&:first) - (labor_months | material_months).sort.freeze + return data_months unless @date_range + + start_month = @date_range.first.beginning_of_month + end_month = @date_range.last.beginning_of_month + + (start_month..end_month) + .step(1.month) + .to_a + .freeze end def cost_type_names @@ -58,6 +64,7 @@ module Costs def spent_material_by_month_and_type cost_entries_by_month_and_type.effective_costs_sum + .transform_keys { |(month, name)| [month.to_date, name] } end def spent_labor @@ -65,7 +72,7 @@ module Costs end def spent_labor_by_month - time_entries_by_month.effective_costs_sum + time_entries_by_month.effective_costs_sum.transform_keys(&:to_date) end def has_spending? @@ -108,6 +115,12 @@ module Costs time_entries.group("date_trunc('month', time_entries.spent_on)") end + def data_months + labor_months = spent_labor_by_month.keys + material_months = spent_material_by_month_and_type.keys.map(&:first) + (labor_months | material_months).sort.freeze + end + def budgeted_work_packages WorkPackage .where(project_id: applicable_projects) diff --git a/modules/costs/spec/models/costs/aggregated_costs_spec.rb b/modules/costs/spec/models/costs/aggregated_costs_spec.rb index ab74c874beb..f9b343d8369 100644 --- a/modules/costs/spec/models/costs/aggregated_costs_spec.rb +++ b/modules/costs/spec/models/costs/aggregated_costs_spec.rb @@ -372,9 +372,10 @@ RSpec.describe Costs::AggregatedCosts do expect(result.count).to eq(3) end - it "returns keys as [month, cost_type_name] pairs" do + it "returns keys as [Date, cost_type_name] pairs" do result = aggregated.spent_material_by_month_and_type expect(result.keys).to all(be_an(Array).and(have_attributes(size: 2))) + expect(result.keys.map(&:first)).to all(be_a(Date)) end it "calculates sums for each group" do @@ -424,6 +425,11 @@ RSpec.describe Costs::AggregatedCosts do expect(result.count).to eq(2) end + it "returns a Date-keyed hash" do + result = aggregated.spent_labor_by_month + expect(result.keys).to all(be_a(Date)) + end + it "calculates sums for each month" do result = aggregated.spent_labor_by_month total = result.sum { |_key, value| value } @@ -460,12 +466,9 @@ RSpec.describe Costs::AggregatedCosts do create(:hourly_rate, user:, project:, rate: 50.0, valid_from: Date.new(2025, 1, 1)) end - it "returns the sorted union of labor and material months" do + it "returns the sorted union of labor and material months as Date objects" do expect(aggregated.months).to eq( - [ - Time.zone.parse("2025-01-01"), - Time.zone.parse("2025-03-01") - ] + [Date.new(2025, 1, 1), Date.new(2025, 3, 1)] ) end end @@ -475,6 +478,19 @@ RSpec.describe Costs::AggregatedCosts do expect(aggregated.months).to eq([]) end end + + context "with a date_range" do + subject(:aggregated) do + described_class.new(project:, current_user: user, + date_range: Date.new(2025, 1, 1)..Date.new(2025, 3, 31)) + end + + it "returns every month in the range as Date objects, regardless of data" do + expect(aggregated.months).to eq( + [Date.new(2025, 1, 1), Date.new(2025, 2, 1), Date.new(2025, 3, 1)] + ) + end + end end describe "#cost_type_names" do From 3f12e61f7e3542a900423cf0a06acbb27c3d3b93 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Feb 2026 20:36:05 -0300 Subject: [PATCH 20/21] Add details links below charts --- .../components/budgets/widgets/budget_by_cost_type.html.erb | 3 +++ modules/budgets/config/locales/en.yml | 1 + .../costs/app/components/costs/widgets/actual_costs.html.erb | 3 +++ modules/costs/config/locales/en.yml | 1 + 4 files changed, 8 insertions(+) diff --git a/modules/budgets/app/components/budgets/widgets/budget_by_cost_type.html.erb b/modules/budgets/app/components/budgets/widgets/budget_by_cost_type.html.erb index fcf012c7353..3efd5e91201 100644 --- a/modules/budgets/app/components/budgets/widgets/budget_by_cost_type.html.erb +++ b/modules/budgets/app/components/budgets/widgets/budget_by_cost_type.html.erb @@ -59,6 +59,9 @@ See COPYRIGHT and LICENSE files for more details. }.to_json ) end + flex.with_row(mt: 3) do + render(Primer::Beta::Link.new(href: projects_budgets_path(project))) { t(".view_details") } + end end else render(Primer::Beta::Blankslate.new) do |blankslate| diff --git a/modules/budgets/config/locales/en.yml b/modules/budgets/config/locales/en.yml index d24d6264ae0..2153a79677c 100644 --- a/modules/budgets/config/locales/en.yml +++ b/modules/budgets/config/locales/en.yml @@ -102,6 +102,7 @@ en: zero: "no subprojects" one: "1 subproject" other: "%{count} subprojects" + view_details: "View budget details" events: budget: "Budget edited" diff --git a/modules/costs/app/components/costs/widgets/actual_costs.html.erb b/modules/costs/app/components/costs/widgets/actual_costs.html.erb index 36237d52fdd..c3cc196ebff 100644 --- a/modules/costs/app/components/costs/widgets/actual_costs.html.erb +++ b/modules/costs/app/components/costs/widgets/actual_costs.html.erb @@ -41,6 +41,9 @@ See COPYRIGHT and LICENSE files for more details. }.to_json ) end + flex.with_row(mt: 3) do + render(Primer::Beta::Link.new(href: cost_reports_path(project))) { t(".view_details") } + end end else render(Primer::Beta::Blankslate.new) do |blankslate| diff --git a/modules/costs/config/locales/en.yml b/modules/costs/config/locales/en.yml index b6a6f69e712..b0d897dd5d1 100644 --- a/modules/costs/config/locales/en.yml +++ b/modules/costs/config/locales/en.yml @@ -258,6 +258,7 @@ en: heading: "Start tracking your time and costs" description: "Get an overview of your costs and logged time to monitor progress of your project" action: "Log time" + view_details: "View actual costs details" ee: features: From 81219545f73e3eaa1476f388d8fbec1a29f96d8d Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 13 Feb 2026 20:37:26 -0300 Subject: [PATCH 21/21] Change widget title to 'Actual costs by month' --- modules/costs/config/locales/en.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/costs/config/locales/en.yml b/modules/costs/config/locales/en.yml index b0d897dd5d1..eee2eed4809 100644 --- a/modules/costs/config/locales/en.yml +++ b/modules/costs/config/locales/en.yml @@ -253,7 +253,7 @@ en: costs: widgets: actual_costs: - title: "Actual costs" + title: "Actual costs by month" blankslate: heading: "Start tracking your time and costs" description: "Get an overview of your costs and logged time to monitor progress of your project"