Merge pull request #20966 from opf/feature/66124-overview-widget-budgets

[#66124] Overview widgets for Budgets
This commit is contained in:
Henriette Darge
2026-02-18 14:15:42 +01:00
committed by GitHub
52 changed files with 3458 additions and 16 deletions
+31
View File
@@ -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",
@@ -33159,6 +33180,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",
@@ -37342,6 +37368,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",
+2
View File
@@ -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",
+4
View File
@@ -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 });
}
}
@@ -1,9 +1,13 @@
<div class="position-relative mx-auto" style="height:60vh">
<canvas baseChart
[data]="lineChartData()"
[options]="lineChartOptions()"
[type]="'line'"></canvas>
</div>
@if (hasChartData()) {
<div class="position-relative mx-auto" style="height:60vh">
<canvas baseChart
[data]="lineChartData()"
[options]="lineChartOptions()"
[type]="'line'"></canvas>
</div>
} @else {
<op-no-results />
}
@if (isDevMode) {
<hr/>
@@ -27,9 +27,10 @@
//++
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 { 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)
@@ -60,7 +65,7 @@ export class BurndownChartComponent {
.reduce((a, b) => Math.max(a, b), 0);
});
readonly lineChartOptions = computed<ChartOptions<'line'>>(() => ({
readonly lineChartOptions:Signal<ChartOptions<'line'>> = computed<ChartOptions<'line'>>(() => ({
scales: {
x: {
title: {
@@ -0,0 +1,8 @@
<div class="blankslate-container">
<div class="blankslate">
<ng-content select="blankslate-icon"></ng-content>
<ng-content select="blankslate-heading"/>
<ng-content select="blankslate-description"></ng-content>
<ng-content select="blankslate-action"></ng-content>
</div>
</div>
@@ -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: '<svg octicon [icon]="icon()" />',
imports: [DynamicIconDirective],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BlankslateIconComponent {
readonly icon = input<string>();
}
@Component({
selector: 'blankslate-heading',
template: '<h3 class="blankslate-heading"><ng-content/></h3>',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BlankslateHeadingComponent {
}
@Component({
selector: 'blankslate-description',
template: '<p><ng-content/></p>',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BlankslateDescriptionComponent {
}
@Component({
selector: 'blankslate-action',
template: '<div class="blankslate-action"><ng-content/></div>',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BlankslateActionComponent {
}
@Component({
selector: 'blankslate',
templateUrl: './blankslate.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BlankslateComponent {
}
@@ -0,0 +1,7 @@
<blankslate>
<blankslate-icon icon="graph" />
<blankslate-heading>{{ title() }}</blankslate-heading>
@if (message()) {
<blankslate-description>{{ message() }}</blankslate-description>
}
</blankslate>
@@ -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 title = input(this.i18n.t('js.label_no_data'));
readonly message = input<string>();
}
@@ -0,0 +1,149 @@
//-- 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 },
},
},
};
type FormatCurrency = (value:number) => string;
interface TooltipContext<TType extends 'bar' | 'pie'> {
chart:{ canvas:HTMLCanvasElement };
tooltip:TooltipModel<TType>;
}
function applyTooltipPosition<TType extends 'bar' | 'pie'>(
context:TooltipContext<TType>,
popoverHtml:ReturnType<typeof html>,
tooltipId:string,
) {
render(popoverHtml, document.body);
const tooltipEl = document.getElementById(tooltipId)!;
if (context.tooltip.opacity === 0) {
tooltipEl.style.opacity = '0';
return;
}
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';
}
function renderColorDot(color:string) {
return html`<span style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: ${color}; vertical-align: baseline; margin-right: 4px"></span>`;
}
function renderTooltipItem(
color:string,
label:string,
formattedValue:string,
dateStr?:string,
):ReturnType<typeof html> {
const header = dateStr
? html`<div><strong style="margin-right: 8px">${dateStr}</strong>${renderColorDot(color)}<strong>${label}</strong></div>`
: html`<div>${renderColorDot(color)}<strong>${label}</strong></div>`;
return html`
<li class="mb-1">
${header}
<div class="f4" style="font-variant-numeric: tabular-nums">${formattedValue}</div>
</li>`;
}
function renderTooltipPopover(tooltipId:string, items:ReturnType<typeof html>[]):ReturnType<typeof html> {
return html`
<div class="Popover" id="${tooltipId}" style="position: fixed; top: 0; left: 0; pointer-events: none">
<div class="Box Popover-message Popover-message--left-top ml-2 mx-auto p-2 text-left text-small">
<ul class="list-style-none ml-0">
${items}
</ul>
</div>
</div>`;
}
export function createBarTooltipRenderer(formatCurrency:FormatCurrency) {
return function(context:TooltipContext<'bar'>) {
const { tooltip } = context;
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 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');
};
}
@@ -0,0 +1,7 @@
@if (hasChartData()) {
<div class="position-relative mx-auto" style="height:60vh;max-height:500px;">
<canvas baseChart [data]="barChartData()" [options]="barChartOptions()" type="bar"></canvas>
</div>
} @else {
<op-no-results [title]="text.noResults.title" [message]="text.noResults.description" />
}
@@ -0,0 +1,113 @@
//-- 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,
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';
import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts';
@Component({
selector: 'opce-actual-costs',
templateUrl: './actual-costs.component.html',
imports: [BaseChartDirective, NoResultsComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [provideCharts(withDefaultRegisterables(PrimerColorsPlugin))],
})
export class ActualCostsComponent {
private readonly i18n = inject(I18nService);
readonly chartData = input.required<string>();
readonly currency = input<string>('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<ChartData<'bar'>>(() => JSON.parse(this.chartData()) as ChartData<'bar'>);
readonly hasChartData = computed(() => this.barChartData().datasets.length > 0);
readonly barChartOptions:Signal<ChartConfiguration<'bar'>['options']> = computed<ChartConfiguration<'bar'>['options']>(() => ({
font: chartFont,
aspectRatio: 1.5,
scales: {
x: {
stacked: true,
type: 'time',
time: {
unit: 'month',
},
},
y: {
stacked: true,
ticks: {
callback: (value) => this.formatCurrencyCompact(value as number),
},
},
},
plugins: {
...chartLegend,
'primer-colors': { labelBased: true },
tooltip: {
enabled: false,
external: createBarTooltipRenderer(this.formatCurrency.bind(this)),
},
},
}));
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);
}
}
@@ -0,0 +1,7 @@
@if (hasChartData()) {
<div class="position-relative mx-auto" style="height:60vh;max-height:500px">
<canvas baseChart [data]="pieChartData()" [options]="pieChartOptions()" type="pie"></canvas>
</div>
} @else {
<op-no-results [title]="text.noResults.title" [message]="text.noResults.description" />
}
@@ -0,0 +1,86 @@
//-- 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,
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';
import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts';
@Component({
selector: 'opce-budget-by-cost-type',
templateUrl: './budget-by-cost-type.component.html',
imports: [BaseChartDirective, NoResultsComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [provideCharts(withDefaultRegisterables(PrimerColorsPlugin))],
})
export class BudgetByCostTypeComponent {
private readonly i18n = inject(I18nService);
readonly chartData = input.required<string>();
readonly currency = input<string>('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<ChartData<'pie'>>(() => JSON.parse(this.chartData()) as ChartData<'pie'>);
readonly hasChartData = computed(() => this.pieChartData().datasets[0].data.length > 0);
readonly pieChartOptions:Signal<ChartConfiguration<'pie'>['options']> = computed<ChartConfiguration<'pie'>['options']>(() => ({
font: chartFont,
plugins: {
...chartLegend,
'primer-colors': { labelBased: true },
tooltip: {
enabled: false,
external: createPieTooltipRenderer(this.formatCurrency.bind(this)),
},
},
}));
private formatCurrency(value:number):string {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: this.currency(),
maximumFractionDigits: 0,
}).format(value);
}
}
@@ -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,
@@ -0,0 +1,10 @@
<blankslate>
<blankslate-icon icon="circle-slash" />
<blankslate-heading>{{ name() }}</blankslate-heading>
<blankslate-description>{{ message() }}</blankslate-description>
@if (actionText()) {
<blankslate-action>
<a href="#" class="Link" (click)="onActionClick($event)">{{ actionText() }}</a>
</blankslate-action>
}
</blankslate>
@@ -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<string>();
readonly message = input<string>();
readonly actionText = input<string>();
readonly action = output<void>();
onActionClick(event:Event) {
event.preventDefault?.();
this.action.emit();
}
}
@@ -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,
],
})
@@ -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<string, SVGData> = {
x: xIconData,
star: starIconData,
'circle-slash': circleSlashIconData,
graph: graphIconData,
// TODO add more icons
};
@@ -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<TType extends ChartType> {
'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<string, number> {
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<number>();
const map = new Map<string, number>();
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<ChartType, PrimerColorsPluginOptions> = {
id: 'primer-colors',
defaults: { enabled: true },
@@ -135,7 +187,17 @@ const plugin:Plugin<ChartType, PrimerColorsPluginOptions> = {
}
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 {
@@ -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
@@ -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)
@@ -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
@@ -0,0 +1,81 @@
<%# -- 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
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|
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
%>
@@ -0,0 +1,74 @@
# 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
chart_entries.keys
end
def chart_data
chart_entries.values
end
private
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
@@ -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
%>
@@ -0,0 +1,91 @@
# 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
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
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 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
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
@@ -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 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.visible_costs(current_user).sum(&:costs)
end
def budgeted_material_by_type
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.visible_costs(current_user).sum(&:costs)
end
def budgeted_total
budgeted_base + budgeted_material + budgeted_labor
end
def has_budgets?
budget_count.positive?
end
private
def budgets
Budget
.visible(current_user)
.where(project_id: applicable_projects)
end
def material_budget_items
MaterialBudgetItem
.joins(budget: :project)
.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(MaterialBudgetItem.visible(current_user))
.where(projects: { id: applicable_projects })
.group(:name)
.order(name: :asc)
end
def labor_budget_items
LaborBudgetItem
.joins(budget: :project)
.visible(current_user)
.where(budget: { project_id: applicable_projects })
end
end
end
@@ -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
@@ -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
+34
View File
@@ -70,6 +70,40 @@ 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"
view_details: "View budget details"
events:
budget: "Budget edited"
+6
View File
@@ -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"
@@ -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 view_hourly_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
@@ -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:, 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)
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:, 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)
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
@@ -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
@@ -0,0 +1,315 @@
# 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!(:budget) { create(:budget, project:) }
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
work_package.update!(budget:)
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
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
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
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
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
before do
work_package.update(budget:)
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
before do
work_package.update(budget:)
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
before do
work_package.update(budget:)
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
work_package.update!(budget:)
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
@@ -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
@@ -0,0 +1,64 @@
<%# -- 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
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|
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
%>
@@ -0,0 +1,87 @@
# 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_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,
to: :@aggregated_costs
def initialize(...)
super
@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
def chart_labels
months.map { |month| month.to_date.iso8601 }
end
def chart_datasets
return [] unless months.any?
[labor_dataset, *material_datasets].compact
end
private
def has_required_permissions?
REQUIRED_PERMISSIONS.all? { |perm| current_user.allowed_in_project?(perm, project) }
end
def labor_dataset
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.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
end
end
+5
View File
@@ -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
@@ -0,0 +1,130 @@
# 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
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
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
.transform_keys { |(month, name)| [month.to_date, name] }
end
def spent_labor
time_entries.effective_costs_sum
end
def spent_labor_by_month
time_entries_by_month.effective_costs_sum.transform_keys(&:to_date)
end
def has_spending?
cost_entries.exists? || time_entries.exists?
end
private
def cost_entries
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
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)
.on_work_packages(budgeted_work_packages)
.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
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)
.where.associated(:budget)
end
end
end
+4
View File
@@ -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
+10
View File
@@ -250,6 +250,16 @@ 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 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"
action: "Log time"
view_details: "View actual costs details"
ee:
features:
time_entry_time_restrictions: Require exact time tracking
+6
View File
@@ -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"
@@ -0,0 +1,200 @@
# 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_budgets
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!(:budget) { create(:budget, project:) }
let(:work_package) { create(:work_package, project:, budget:) }
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!(: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,
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!(:budget) { create(:budget, project:) }
let(:work_package) { create(:work_package, project:, budget:) }
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
@@ -0,0 +1,785 @@
# 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
view_budgets] })
end
shared_let(:budget) { create(:budget, project:) }
shared_let(:work_package) { create(:work_package, project:, budget:) }
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 [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
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 "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 }
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 as Date objects" do
expect(aggregated.months).to eq(
[Date.new(2025, 1, 1), Date.new(2025, 3, 1)]
)
end
end
context "with no entries" do
it "returns an empty array" 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
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
view_budgets])])
# 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
view_budgets])])
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
view_budgets])])
end
context "with spending in child projects" do
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,
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
@@ -2,3 +2,4 @@
display: flex
flex-wrap: wrap
flex-basis: 100%
gap: var(--stack-gap-normal)
@@ -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"
+1
View File
@@ -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:
@@ -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
@@ -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
@@ -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