mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Merge pull request #20966 from opf/feature/66124-overview-widget-budgets
[#66124] Overview widgets for Budgets
This commit is contained in:
Generated
+31
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
@@ -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" />
|
||||
}
|
||||
+86
@@ -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,
|
||||
|
||||
+10
@@ -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>
|
||||
+58
@@ -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
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user