Budget graph in project details (#3406)
* do not copy description if restarting via calendar * added support to display negative durations * fix charts for larger numbers
This commit is contained in:
@@ -51,26 +51,30 @@ export default class KimaiDateUtils extends KimaiPlugin {
|
||||
* @returns {string|*}
|
||||
*/
|
||||
formatMomentDuration(duration) {
|
||||
const hours = parseInt(duration.asHours()).toString();
|
||||
const hours = parseInt(duration.asHours());
|
||||
const minutes = duration.minutes();
|
||||
const seconds = duration.seconds();
|
||||
|
||||
return this.formatTime(hours, minutes, seconds);
|
||||
return this.formatTime(hours, minutes);
|
||||
}
|
||||
|
||||
formatTime(hours, minutes, seconds) {
|
||||
if (hours < 0 || minutes < 0 || seconds < 0) {
|
||||
return '?';
|
||||
formatTime(hours, minutes) {
|
||||
let format = this.getConfiguration('formatDuration');
|
||||
|
||||
if (hours < 0 || minutes < 0) {
|
||||
hours = Math.abs(hours);
|
||||
minutes = Math.abs(minutes);
|
||||
if (minutes > 0 || hours > 0) {
|
||||
format = '-' + format;
|
||||
}
|
||||
}
|
||||
|
||||
// special case for hours, as they can overflow the 24h barrier - Kimai does not support days as duration unit
|
||||
if (hours.length === 1) {
|
||||
if (hours < 10) {
|
||||
hours = '0' + hours;
|
||||
}
|
||||
|
||||
const format = this.getConfiguration('formatDuration');
|
||||
|
||||
return format.replace('%h', hours).replace('%m', ('0' + minutes).substr(-2)).replace('%s', ('0' + seconds).substr(-2));
|
||||
return format.replace('%h', hours).replace('%m', ('0' + minutes).substr(-2));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
public/build/app.429626c8.js
Normal file
2
public/build/app.429626c8.js
Normal file
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"build/runtime.b8e7bb04.js",
|
||||
"build/app.18632e12.js"
|
||||
"build/app.429626c8.js"
|
||||
],
|
||||
"css": [
|
||||
"build/app.7d391c0d.css"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"build/app.css": "build/app.7d391c0d.css",
|
||||
"build/app.js": "build/app.18632e12.js",
|
||||
"build/app.js": "build/app.429626c8.js",
|
||||
"build/invoice.css": "build/invoice.ccdecd42.css",
|
||||
"build/invoice.js": "build/invoice.19f36eca.js",
|
||||
"build/invoice-pdf.css": "build/invoice-pdf.e73a6dda.css",
|
||||
|
||||
@@ -38,7 +38,8 @@ final class TimesheetEntry implements DragAndDropEntry
|
||||
}
|
||||
|
||||
return [
|
||||
'description' => $this->timesheet->getDescription(),
|
||||
// restarting a timesheet should not copy the description - @version 1.21
|
||||
//'description' => $this->timesheet->getDescription(),
|
||||
'activity' => $this->timesheet->getActivity() !== null ? $this->timesheet->getActivity()->getId() : null,
|
||||
'project' => $this->timesheet->getProject() !== null ? $this->timesheet->getProject()->getId() : null,
|
||||
'tags' => $tags,
|
||||
|
||||
@@ -17,7 +17,10 @@ final class Month extends Timesheet
|
||||
private $billableDuration = 0;
|
||||
private $billableRate = 0.00;
|
||||
|
||||
public function __construct(string $month)
|
||||
/**
|
||||
* @param string|int $month
|
||||
*/
|
||||
public function __construct($month)
|
||||
{
|
||||
$monthNumber = (int) $month;
|
||||
if ($monthNumber < 1 || $monthNumber > 12) {
|
||||
|
||||
@@ -16,6 +16,8 @@ final class Year extends Timesheet
|
||||
* @var Month[]
|
||||
*/
|
||||
private $months = [];
|
||||
private $billableDuration = 0;
|
||||
private $billableRate = 0.00;
|
||||
|
||||
public function __construct(string $year)
|
||||
{
|
||||
@@ -50,4 +52,24 @@ final class Year extends Timesheet
|
||||
{
|
||||
return array_values($this->months);
|
||||
}
|
||||
|
||||
public function getBillableDuration(): int
|
||||
{
|
||||
return $this->billableDuration;
|
||||
}
|
||||
|
||||
public function setBillableDuration(int $billableDuration): void
|
||||
{
|
||||
$this->billableDuration = $billableDuration;
|
||||
}
|
||||
|
||||
public function getBillableRate(): float
|
||||
{
|
||||
return $this->billableRate;
|
||||
}
|
||||
|
||||
public function setBillableRate(float $billableRate): void
|
||||
{
|
||||
$this->billableRate = $billableRate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +209,7 @@ class TimesheetCountedStatistic implements \JsonSerializable
|
||||
/**
|
||||
* @param float $recordInternalRate
|
||||
* @return $this
|
||||
* @deprecated since 1.15 use setInternalRate() instead
|
||||
*/
|
||||
public function setRecordInternalRate($recordInternalRate)
|
||||
{
|
||||
|
||||
@@ -392,15 +392,16 @@ class ProjectStatisticService
|
||||
$model = new ProjectDetailsModel($project);
|
||||
$model->setBudgetStatisticModel($this->getBudgetStatisticModel($project, $query->getToday()));
|
||||
|
||||
$years = [];
|
||||
$qb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
$qb
|
||||
->select('COALESCE(SUM(t.duration), 0) as duration')
|
||||
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
|
||||
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
|
||||
->addSelect('COUNT(t.id) as count')
|
||||
->addSelect('t.billable as billable')
|
||||
->andWhere('t.project = :project')
|
||||
->setParameter('project', $query->getProject())
|
||||
->addGroupBy('billable')
|
||||
;
|
||||
|
||||
// fetch stats grouped by ACTIVITY for all time
|
||||
@@ -410,13 +411,31 @@ class ProjectStatisticService
|
||||
->addSelect('a as activity')
|
||||
->addGroupBy('a')
|
||||
;
|
||||
|
||||
/** @var array<ActivityStatistic> $activities */
|
||||
$activities = [];
|
||||
foreach ($qb1->getQuery()->getResult() as $tmp) {
|
||||
$activity = new ActivityStatistic();
|
||||
$activity->setActivity($tmp['activity']);
|
||||
$activity->setRecordRate($tmp['rate']);
|
||||
$activity->setRecordDuration($tmp['duration']);
|
||||
$activity->setRecordInternalRate($tmp['internalRate']);
|
||||
$activity->setCounter($tmp['count']);
|
||||
$activityId = $tmp['activity']->getId();
|
||||
if (!\array_key_exists($activityId, $activities)) {
|
||||
$activity = new ActivityStatistic();
|
||||
$activity->setActivity($tmp['activity']);
|
||||
$activities[$activityId] = $activity;
|
||||
} else {
|
||||
$activity = $activities[$activityId];
|
||||
}
|
||||
|
||||
$activity->setRecordRate($activity->getRecordRate() + $tmp['rate']);
|
||||
$activity->setRecordDuration($activity->getRecordDuration() + $tmp['duration']);
|
||||
$activity->setInternalRate($activity->getInternalRate() + $tmp['internalRate']);
|
||||
$activity->setCounter($activity->getCounter() + $tmp['count']);
|
||||
|
||||
if ($tmp['billable']) {
|
||||
$activity->setDurationBillable($activity->getDurationBillable() + $tmp['duration']);
|
||||
$activity->setRateBillable($activity->getRateBillable() + $tmp['rate']);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($activities as $activity) {
|
||||
$model->addActivity($activity);
|
||||
}
|
||||
// ---------------------------------------------------
|
||||
@@ -438,6 +457,7 @@ class ProjectStatisticService
|
||||
if (!empty($userIds)) {
|
||||
$qb2 = $this->userRepository->createQueryBuilder('u');
|
||||
$qb2->select('u')->where($qb2->expr()->in('u.id', $userIds));
|
||||
/** @var array<int, UserStatistic> $users */
|
||||
$users = [];
|
||||
foreach ($qb2->getQuery()->getResult() as $user) {
|
||||
$users[$user->getId()] = new UserStatistic($user);
|
||||
@@ -448,31 +468,95 @@ class ProjectStatisticService
|
||||
$year = $model->getUserYear($tmp['year'], $user);
|
||||
if ($year === null) {
|
||||
$year = new Year($tmp['year']);
|
||||
for ($i = 1; $i < 13; $i++) {
|
||||
$year->setMonth(new Month($i));
|
||||
}
|
||||
$model->setUserYear($year, $user);
|
||||
}
|
||||
$month = new Month($tmp['month']);
|
||||
$month->setTotalRate($tmp['rate']);
|
||||
$month->setTotalDuration($tmp['duration']);
|
||||
$month->setTotalInternalRate($tmp['internalRate']);
|
||||
$year->setMonth($month);
|
||||
$users[$tmp['user']]->addValuesFromMonth($month);
|
||||
$month = $year->getMonth($tmp['month']);
|
||||
if ($month === null) {
|
||||
$month = new Month($tmp['month']);
|
||||
$year->setMonth($month);
|
||||
}
|
||||
$month->setTotalRate($month->getTotalRate() + $tmp['rate']);
|
||||
$month->setTotalDuration($month->getTotalDuration() + $tmp['duration']);
|
||||
$month->setTotalInternalRate($month->getTotalInternalRate() + $tmp['internalRate']);
|
||||
|
||||
if ($tmp['billable']) {
|
||||
$month->setBillableDuration($month->getBillableDuration() + $tmp['duration']);
|
||||
$month->setBillableRate($month->getBillableRate() + $tmp['rate']);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($users as $userId => $statistic) {
|
||||
foreach ($model->getYears() as $year) {
|
||||
$statYear = $model->getUserYear($year->getYear(), $statistic->getUser());
|
||||
if ($statYear === null) {
|
||||
continue;
|
||||
}
|
||||
foreach ($statYear->getMonths() as $month) {
|
||||
$statistic->addValuesFromMonth($month);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------
|
||||
|
||||
$years = [];
|
||||
|
||||
// make sure that we have all month between project start and end
|
||||
if ($project->getStart() !== null) {
|
||||
if ($project->getEnd() !== null) {
|
||||
$end = clone $project->getEnd();
|
||||
} else {
|
||||
$end = clone $query->getToday();
|
||||
$end->setDate((int) $end->format('Y'), 12, 31);
|
||||
}
|
||||
|
||||
$start = clone $project->getStart();
|
||||
$start->setDate((int) $start->format('Y'), (int) $start->format('m'), 1);
|
||||
$start->setTime(0, 0, 0);
|
||||
|
||||
while ($start !== false && $start < $end) {
|
||||
$year = $start->format('Y');
|
||||
if (!\array_key_exists($year, $years)) {
|
||||
$years[$year] = new Year($year);
|
||||
}
|
||||
$tmp = $years[$year];
|
||||
$tmp->setMonth(new Month($start->format('m')));
|
||||
$start = $start->modify('+1 month');
|
||||
}
|
||||
}
|
||||
|
||||
// fetch stats grouped by YEARS
|
||||
$qb1 = clone $qb;
|
||||
$qb1
|
||||
->addSelect('YEAR(t.date) as year')
|
||||
->addGroupBy('year')
|
||||
;
|
||||
foreach ($qb1->getQuery()->getResult() as $year) {
|
||||
$tmp = new Year($year['year']);
|
||||
$tmp->setTotalRate($year['rate']);
|
||||
$tmp->setTotalInternalRate($year['internalRate']);
|
||||
$tmp->setTotalDuration($year['duration']);
|
||||
$years[$year['year']] = $tmp;
|
||||
|
||||
foreach ($qb1->getQuery()->getResult() as $year) {
|
||||
if (!\array_key_exists($year['year'], $years)) {
|
||||
$tmp = new Year($year['year']);
|
||||
for ($i = 1; $i < 13; $i++) {
|
||||
$tmp->setMonth(new Month($i));
|
||||
}
|
||||
$years[$year['year']] = $tmp;
|
||||
} else {
|
||||
$tmp = $years[$year['year']];
|
||||
}
|
||||
$tmp->setTotalRate($tmp->getTotalRate() + $year['rate']);
|
||||
$tmp->setTotalInternalRate($tmp->getTotalInternalRate() + $year['internalRate']);
|
||||
$tmp->setTotalDuration($tmp->getTotalDuration() + $year['duration']);
|
||||
|
||||
if ($year['billable']) {
|
||||
$tmp->setBillableDuration($tmp->getBillableDuration() + $year['duration']);
|
||||
$tmp->setBillableRate($tmp->getBillableRate() + $year['rate']);
|
||||
}
|
||||
}
|
||||
|
||||
$yearActivities = [];
|
||||
foreach ($years as $yearName => $yearStat) {
|
||||
// fetch yearly stats grouped by ACTIVITY and YEAR
|
||||
$qb2 = clone $qb;
|
||||
$qb2
|
||||
@@ -480,20 +564,41 @@ class ProjectStatisticService
|
||||
->addSelect('a as activity')
|
||||
->addSelect('YEAR(t.date) as year')
|
||||
->andWhere('YEAR(t.date) = :year')
|
||||
->setParameter('year', $year['year'])
|
||||
->setParameter('year', $yearName)
|
||||
->addGroupBy('year')
|
||||
->addGroupBy('a')
|
||||
;
|
||||
|
||||
foreach ($qb2->getQuery()->getResult() as $tmp) {
|
||||
$activity = new ActivityStatistic();
|
||||
$activity->setActivity($tmp['activity']);
|
||||
$activity->setRecordRate($tmp['rate']);
|
||||
$activity->setRecordDuration($tmp['duration']);
|
||||
$activity->setRecordInternalRate($tmp['internalRate']);
|
||||
$activity->setCounter($tmp['count']);
|
||||
$model->addYearActivity($tmp['year'], $activity);
|
||||
$activityId = $tmp['activity']->getId();
|
||||
if (!\array_key_exists($yearName, $yearActivities)) {
|
||||
$yearActivities[$yearName] = [];
|
||||
}
|
||||
if (!\array_key_exists($activityId, $yearActivities[$yearName])) {
|
||||
$activity = new ActivityStatistic();
|
||||
$activity->setActivity($tmp['activity']);
|
||||
$yearActivities[$yearName][$activityId] = $activity;
|
||||
} else {
|
||||
$activity = $yearActivities[$yearName][$activityId];
|
||||
}
|
||||
$activity->setRecordRate($activity->getRecordRate() + $tmp['rate']);
|
||||
$activity->setRecordDuration($activity->getRecordDuration() + $tmp['duration']);
|
||||
$activity->setInternalRate($activity->getInternalRate() + $tmp['internalRate']);
|
||||
$activity->setCounter($activity->getCounter() + $tmp['count']);
|
||||
|
||||
if ($tmp['billable']) {
|
||||
$activity->setDurationBillable($activity->getDurationBillable() + $tmp['duration']);
|
||||
$activity->setRateBillable($activity->getRateBillable() + $tmp['rate']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($yearActivities as $year => $activities) {
|
||||
foreach ($activities as $activity) {
|
||||
$model->addYearActivity($year, $activity);
|
||||
}
|
||||
}
|
||||
|
||||
$model->setYears(array_values($years));
|
||||
// ---------------------------------------------------
|
||||
|
||||
@@ -506,11 +611,19 @@ class ProjectStatisticService
|
||||
->addGroupBy('month')
|
||||
;
|
||||
foreach ($qb1->getQuery()->getResult() as $month) {
|
||||
$tmp = new Month($month['month']);
|
||||
$tmp->setTotalRate($month['rate']);
|
||||
$tmp->setTotalInternalRate($month['internalRate']);
|
||||
$tmp->setTotalDuration($month['duration']);
|
||||
$model->getYear($month['year'])->setMonth($tmp);
|
||||
$tmp = $model->getYear($month['year'])->getMonth($month['month']);
|
||||
if ($tmp === null) {
|
||||
$tmp = new Month($month['month']);
|
||||
$model->getYear($month['year'])->setMonth($tmp);
|
||||
}
|
||||
$tmp->setTotalRate($tmp->getTotalRate() + $month['rate']);
|
||||
$tmp->setTotalInternalRate($tmp->getTotalInternalRate() + $month['internalRate']);
|
||||
$tmp->setTotalDuration($tmp->getTotalDuration() + $month['duration']);
|
||||
|
||||
if ($month['billable']) {
|
||||
$tmp->setBillableDuration($tmp->getBillableDuration() + $month['duration']);
|
||||
$tmp->setBillableRate($tmp->getBillableRate() + $month['rate']);
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------
|
||||
|
||||
|
||||
@@ -158,7 +158,12 @@ final class ProjectDetailsModel
|
||||
*/
|
||||
public function setYears(array $years): void
|
||||
{
|
||||
$this->years = $years;
|
||||
$all = [];
|
||||
foreach ($years as $year) {
|
||||
$all[$year->getYear()] = $year;
|
||||
}
|
||||
ksort($all);
|
||||
$this->years = array_values($all);
|
||||
}
|
||||
|
||||
public function getBudgetStatisticModel(): ?BudgetStatisticModel
|
||||
|
||||
@@ -138,7 +138,7 @@ class ActivityRepository extends EntityRepository
|
||||
$stats->setCounter($timesheetResult['amount']);
|
||||
$stats->setRecordDuration($timesheetResult['duration']);
|
||||
$stats->setRecordRate($timesheetResult['rate']);
|
||||
$stats->setRecordInternalRate($timesheetResult['internal_rate']);
|
||||
$stats->setInternalRate($timesheetResult['internal_rate']);
|
||||
}
|
||||
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
@@ -124,7 +124,7 @@ class CustomerRepository extends EntityRepository
|
||||
$stats->setCounter($amount);
|
||||
$stats->setRecordDuration($duration);
|
||||
$stats->setRecordRate($rate);
|
||||
$stats->setRecordInternalRate($rateInternal);
|
||||
$stats->setInternalRate($rateInternal);
|
||||
}
|
||||
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
@@ -140,7 +140,7 @@ class ProjectRepository extends EntityRepository
|
||||
$stats->setCounter($timesheetResult['amount']);
|
||||
$stats->setRecordDuration($timesheetResult['duration']);
|
||||
$stats->setRecordRate($timesheetResult['rate']);
|
||||
$stats->setRecordInternalRate($timesheetResult['internal_rate']);
|
||||
$stats->setInternalRate($timesheetResult['internal_rate']);
|
||||
}
|
||||
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
@@ -69,6 +69,7 @@ final class LocaleFormatExtensions extends AbstractExtension
|
||||
new TwigFilter('hour24', [$this, 'hour24']),
|
||||
new TwigFilter('duration', [$this, 'duration']),
|
||||
new TwigFilter('chart_duration', [$this, 'durationChart']),
|
||||
new TwigFilter('chart_money', [$this, 'moneyChart']),
|
||||
new TwigFilter('duration_decimal', [$this, 'durationDecimal']),
|
||||
new TwigFilter('money', [$this, 'money']),
|
||||
new TwigFilter('currency', [$this, 'currency']),
|
||||
@@ -343,6 +344,11 @@ final class LocaleFormatExtensions extends AbstractExtension
|
||||
return number_format(($duration / 3600), 2, '.', '');
|
||||
}
|
||||
|
||||
public function moneyChart($money): string
|
||||
{
|
||||
return number_format($money, 2, '.', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|float $amount
|
||||
* @return bool|false|string
|
||||
|
||||
@@ -22,8 +22,10 @@ class Duration
|
||||
* @deprecated since 1.13
|
||||
*/
|
||||
public const FORMAT_SECONDS = 'seconds';
|
||||
|
||||
public const FORMAT_WITH_SECONDS = '%h:%m:%s';
|
||||
/**
|
||||
* @deprecated since 1.21
|
||||
*/
|
||||
public const FORMAT_WITH_SECONDS = '%h:%m';
|
||||
public const FORMAT_NO_SECONDS = '%h:%m';
|
||||
|
||||
/**
|
||||
@@ -39,19 +41,21 @@ class Duration
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($seconds < 0) {
|
||||
if ($seconds <= -60) {
|
||||
$format = '-' . $format;
|
||||
}
|
||||
$seconds = abs($seconds);
|
||||
}
|
||||
|
||||
$hour = (int) floor($seconds / 3600);
|
||||
$minute = (int) floor((int) ($seconds / 60) % 60);
|
||||
|
||||
$hour = $hour > 9 ? $hour : '0' . $hour;
|
||||
$minute = $minute > 9 ? $minute : '0' . $minute;
|
||||
|
||||
$second = $seconds % 60;
|
||||
$second = $second > 9 ? $second : '0' . $second;
|
||||
|
||||
$formatted = str_replace('%h', $hour, $format);
|
||||
$formatted = str_replace('%m', $minute, $formatted);
|
||||
|
||||
return str_replace('%s', $second, $formatted);
|
||||
return str_replace('%m', $minute, $formatted);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -122,10 +122,6 @@ final class LocaleFormatter
|
||||
|
||||
private function formatDuration(int $seconds, string $format): string
|
||||
{
|
||||
if ($seconds < 0) {
|
||||
return '?';
|
||||
}
|
||||
|
||||
return $this->durationFormatter->format($seconds, $format);
|
||||
}
|
||||
|
||||
|
||||
86
templates/project/charts.html.twig
Normal file
86
templates/project/charts.html.twig
Normal file
@@ -0,0 +1,86 @@
|
||||
{# project \App\Entity\Project #}
|
||||
{# details \App\Reporting\ProjectDetails\ProjectDetailsModel #}
|
||||
{% macro project_budget(project, details, prefix) %}
|
||||
{% set chartPrefix = (prefix is null ? random() : prefix) ~ 'Budget' %}
|
||||
{% set chart = is_granted('budget', project) %}
|
||||
{% set showMoneyBudget = is_granted('budget', project) and project.hasBudget() %}
|
||||
{% set showTimeBudget = is_granted('time', project) and project.hasTimeBudget() %}
|
||||
{% if showMoneyBudget or showTimeBudget %}
|
||||
{% from "macros/charts.html.twig" import bar_chart %}
|
||||
{% set currency = project.customer.currency %}
|
||||
{% if project.isMonthlyBudget() %}
|
||||
<h4>
|
||||
{%- if showMoneyBudget %}{{ 'label.budget'|trans }}{% else %}{{ 'label.timeBudget'|trans }}{% endif -%}
|
||||
: {{ 'label.budgetType_month'|trans }}
|
||||
</h4>
|
||||
{% set chartData = [] %}
|
||||
{% set chartLabels = [] %}
|
||||
{# year \App\Model\Statistic\Year #}
|
||||
{% for year in details.years %}
|
||||
{# month \App\Model\Statistic\Month #}
|
||||
{% for month in year.months %}
|
||||
{% set monthDate = date(year.year ~ '-' ~ month.month ~ '-01 00:00:00') %}
|
||||
{% if project.end is null or monthDate < project.end %}
|
||||
{% set totalBudget = project.getTimeBudget() - month.billableDuration %}
|
||||
{% set projectBudget = project.getTimeBudget() %}
|
||||
{% if showMoneyBudget %}
|
||||
{% set totalBudget = project.getBudget() - month.billableRate %}
|
||||
{% set projectBudget = project.getBudget() %}
|
||||
{% endif %}
|
||||
{% set chartLabels = chartLabels|merge([monthDate|month_name ~ ' ' ~ year.year]) %}
|
||||
{% set chartValue = {
|
||||
'label': (showMoneyBudget ? totalBudget|money(currency) : totalBudget|duration),
|
||||
'value': '' ~ (showMoneyBudget ? totalBudget|chart_money : totalBudget|chart_duration),
|
||||
} %}
|
||||
{% if totalBudget < 0 %}
|
||||
{% set chartValue = chartValue|merge({'color': 'red'}) %}
|
||||
{% endif %}
|
||||
{% if totalBudget == projectBudget %}
|
||||
{% set chartValue = chartValue|merge({'color': 'green'}) %}
|
||||
{% endif %}
|
||||
{% set chartData = chartData|merge([chartValue]) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{{ bar_chart(chartPrefix, chartLabels, [chartData], {'height': '300px', 'renderEvent': 'render.' ~ chartPrefix}) }}
|
||||
{% else %}
|
||||
<h4>
|
||||
{%- if showMoneyBudget %}{{ 'label.budget'|trans }}{% else %}{{ 'label.timeBudget'|trans }}{% endif -%}
|
||||
</h4>
|
||||
{% set chartData = [] %}
|
||||
{% set chartLabels = [] %}
|
||||
{% if showMoneyBudget %}
|
||||
{% set totalBudget = project.getBudget() %}
|
||||
{% else %}
|
||||
{% set totalBudget = project.getTimeBudget() %}
|
||||
{% endif %}
|
||||
{# year \App\Model\Statistic\Year #}
|
||||
{% for year in details.years %}
|
||||
{# month \App\Model\Statistic\Month #}
|
||||
{% for month in year.months %}
|
||||
{% set monthDate = date(year.year ~ '-' ~ month.month ~ '-01') %}
|
||||
{% if project.end is null or monthDate < project.end %}
|
||||
{% if showMoneyBudget %}
|
||||
{% set totalBudget = totalBudget - month.billableRate %}
|
||||
{% else %}
|
||||
{% set totalBudget = totalBudget - month.billableDuration %}
|
||||
{% endif %}
|
||||
{% set chartLabels = chartLabels|merge([monthDate|month_name ~ ' ' ~ year.year]) %}
|
||||
{% set chartValue = {
|
||||
'label': (showMoneyBudget ? totalBudget|money(currency) : totalBudget|duration),
|
||||
'value': (showMoneyBudget ? totalBudget|chart_money : totalBudget|chart_duration),
|
||||
} %}
|
||||
{# line chart do not support coloring negative areas #}
|
||||
{#
|
||||
{% if totalBudget < 0 %}
|
||||
{% set chartValue = chartValue|merge({'color': 'red'}) %}
|
||||
{% endif %}
|
||||
#}
|
||||
{% set chartData = chartData|merge([chartValue]) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{{ bar_chart(chartPrefix, chartLabels, [chartData], {'height': '300px', 'renderEvent': 'render.' ~ chartPrefix, 'type': 'line'}) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -34,6 +34,9 @@
|
||||
jQuery('#{{ tableId }}').on('change', function(ev) {
|
||||
jQuery(this).submit();
|
||||
});
|
||||
{% if project is not null %}
|
||||
renderChart('project{{ project.id }}Budget');
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
jQuery('a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
|
||||
@@ -296,6 +299,14 @@
|
||||
{{ widgets.label_customer(project.customer) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr {{ widgets.project_row_attr(project) }}>
|
||||
<th class="w-min">
|
||||
{{ 'label.project'|trans }}
|
||||
</th>
|
||||
<td>
|
||||
{{ widgets.label_project(project) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'stats.durationTotal'|trans }}
|
||||
@@ -309,11 +320,13 @@
|
||||
</th>
|
||||
<td>{{ project_view.rateTotal|money(currency) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'label.billable'|trans }}
|
||||
</th>
|
||||
<td>{{ project_view.billableRate|money(currency) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<table class="table table-hover dataTable">
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'label.last_record'|trans }}
|
||||
@@ -352,13 +365,19 @@
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-6">
|
||||
{% if (showMoneyBudget and project.hasBudget()) or (showTimeBudget and project.hasTimeBudget()) %}
|
||||
{% from "project/charts.html.twig" import project_budget %}
|
||||
{{ project_budget(project, project_details, chartPrefix) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if (showMoneyBudget and project.budget > 0) or (showTimeBudget and project.timeBudget > 0) %}
|
||||
{% if (showMoneyBudget and project.hasBudget()) or (showTimeBudget and project.hasTimeBudget()) %}
|
||||
{% set budgetStats = project_details.budgetStatisticModel %}
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<table class="table table-hover dataTable">
|
||||
{% if showTimeBudget and project.timeBudget > 0 %}
|
||||
{% if showTimeBudget and project.hasTimeBudget() %}
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'label.timeBudget'|trans }}
|
||||
@@ -371,7 +390,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if showMoneyBudget and project.budget > 0 %}
|
||||
{% if showMoneyBudget and project.hasBudget() %}
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'label.budget'|trans }}
|
||||
|
||||
@@ -35,7 +35,6 @@ class TimesheetEntryTest extends TestCase
|
||||
$timesheet->addTag((new Tag())->setName('action test'));
|
||||
|
||||
$expectedData = [
|
||||
'description' => 'hello foo bar',
|
||||
'activity' => null,
|
||||
'project' => null,
|
||||
'tags' => 'bulb,action test',
|
||||
@@ -56,10 +55,9 @@ class TimesheetEntryTest extends TestCase
|
||||
$timesheet = new Timesheet();
|
||||
|
||||
$expectedData = [
|
||||
'description' => null,
|
||||
'activity' => null,
|
||||
'project' => null,
|
||||
'tags' => '',
|
||||
'tags' => null,
|
||||
];
|
||||
|
||||
$sut = new TimesheetEntry($timesheet, '#ddd');
|
||||
|
||||
@@ -32,6 +32,7 @@ abstract class AbstractTimesheetCountedStatisticTest extends TestCase
|
||||
self::assertSame(0, $sut->getDurationExported());
|
||||
self::assertSame(0.0, $sut->getRateExported());
|
||||
self::assertSame(0.0, $sut->getInternalRateExported());
|
||||
self::assertSame(0, $sut->getCounterExported());
|
||||
|
||||
$json = $sut->jsonSerialize();
|
||||
|
||||
@@ -61,12 +62,13 @@ abstract class AbstractTimesheetCountedStatisticTest extends TestCase
|
||||
self::assertInstanceOf(TimesheetCountedStatistic::class, $sut->setRecordDuration(21));
|
||||
self::assertInstanceOf(TimesheetCountedStatistic::class, $sut->setRecordAmount(5));
|
||||
$sut->setRecordAmountBillable(15);
|
||||
self::assertInstanceOf(TimesheetCountedStatistic::class, $sut->setRecordInternalRate(99.09));
|
||||
$sut->setInternalRate(99.09);
|
||||
|
||||
$sut->setDurationBillableExported(199);
|
||||
$sut->setDurationExported(299);
|
||||
$sut->setRateExported(456.48);
|
||||
$sut->setInternalRateExported(27.15);
|
||||
$sut->setCounterExported(538);
|
||||
|
||||
self::assertSame(23.97, $sut->getRecordRate());
|
||||
self::assertSame(21, $sut->getRecordDuration());
|
||||
@@ -78,6 +80,7 @@ abstract class AbstractTimesheetCountedStatisticTest extends TestCase
|
||||
self::assertSame(299, $sut->getDurationExported());
|
||||
self::assertSame(456.48, $sut->getRateExported());
|
||||
self::assertSame(27.15, $sut->getInternalRateExported());
|
||||
self::assertSame(538, $sut->getCounterExported());
|
||||
|
||||
self::assertSame(21, $sut->getValue());
|
||||
|
||||
@@ -106,10 +109,11 @@ abstract class AbstractTimesheetCountedStatisticTest extends TestCase
|
||||
$sut->setRecordRate(23.97);
|
||||
$sut->setRecordDuration(21);
|
||||
$sut->setRecordAmount(5);
|
||||
$sut->setRecordInternalRate(99.09);
|
||||
$sut->setInternalRate(99.09);
|
||||
$sut->setRateBillable(123.456);
|
||||
$sut->setDurationBillable(1234);
|
||||
$sut->setRecordAmountBillable(4321);
|
||||
$sut->setCounterExported(538);
|
||||
|
||||
$sut->setDurationBillableExported(199);
|
||||
$sut->setRateBillableExported(654.23);
|
||||
@@ -132,5 +136,6 @@ abstract class AbstractTimesheetCountedStatisticTest extends TestCase
|
||||
self::assertSame(99.09, $json['rate_internal']);
|
||||
self::assertSame(5, $json['amount']);
|
||||
self::assertSame(4321, $json['amount_billable']);
|
||||
self::assertSame(538, $json['amount_exported']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ class YearTest extends AbstractTimesheetTest
|
||||
self::assertEmpty($sut->getMonths());
|
||||
self::assertIsArray($sut->getMonths());
|
||||
self::assertEquals('1999', $sut->getYear());
|
||||
self::assertSame(0, $sut->getBillableDuration());
|
||||
self::assertSame(0.0, $sut->getBillableRate());
|
||||
}
|
||||
|
||||
public function testSetter()
|
||||
@@ -46,5 +48,10 @@ class YearTest extends AbstractTimesheetTest
|
||||
self::assertInstanceOf(Month::class, $sut->getMonth(2));
|
||||
self::assertInstanceOf(Month::class, $sut->getMonth(3));
|
||||
self::assertNull($sut->getMonth(4));
|
||||
|
||||
$sut->setBillableDuration(123456);
|
||||
$sut->setBillableRate(123.456789);
|
||||
self::assertSame(123456, $sut->getBillableDuration());
|
||||
self::assertSame(123.456789, $sut->getBillableRate());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Tests\Reporting\ProjectDetails;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Entity\User;
|
||||
use App\Model\Statistic\Year;
|
||||
use App\Reporting\ProjectDetails\ProjectDetailsModel;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
@@ -33,4 +34,34 @@ class ProjectDetailsModelTest extends TestCase
|
||||
self::assertNull($sut->getYear('2999'));
|
||||
self::assertNull($sut->getUserYear('2999', new User()));
|
||||
}
|
||||
|
||||
public function testGetYearsSorted()
|
||||
{
|
||||
$project = new Project();
|
||||
$sut = new ProjectDetailsModel($project);
|
||||
|
||||
$y2022 = new Year('2022');
|
||||
$y1999 = new Year('1999');
|
||||
$y2001 = new Year('2001');
|
||||
$y1997 = new Year('1997');
|
||||
$y2015 = new Year('2015');
|
||||
|
||||
$sut->setYears([
|
||||
$y2022,
|
||||
$y1999,
|
||||
$y2001,
|
||||
$y1997,
|
||||
$y2015,
|
||||
]);
|
||||
|
||||
$expected = [
|
||||
$y1997,
|
||||
$y1999,
|
||||
$y2001,
|
||||
$y2015,
|
||||
$y2022,
|
||||
];
|
||||
|
||||
self::assertEquals($expected, $sut->getYears());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class LocaleFormatExtensionsTest extends TestCase
|
||||
private $localeEn = ['en' => ['date' => 'Y-m-d', 'duration' => '%h:%m h', 'date_type' => 'yyyy-MM-dd']];
|
||||
private $localeDe = ['de' => ['date' => 'd.m.Y', 'duration' => '%h:%m h', 'date_type' => 'yyyy-MM-dd']];
|
||||
private $localeRu = ['ru' => ['date' => 'd.m.Y', 'duration' => '%h:%m h', 'date_type' => 'yyyy-MM-dd']];
|
||||
private $localeFake = ['XX' => ['date' => 'd.m.Y', 'duration' => '%h - %m - %s Zeit', 'date_type' => 'yyyy-MM-dd']];
|
||||
private $localeFake = ['XX' => ['date' => 'd.m.Y', 'duration' => '%h - %m Zeit', 'date_type' => 'yyyy-MM-dd']];
|
||||
|
||||
/**
|
||||
* @param string|array $locale
|
||||
@@ -62,7 +62,7 @@ class LocaleFormatExtensionsTest extends TestCase
|
||||
{
|
||||
$filters = [
|
||||
'month_name', 'day_name', 'date_short', 'date_time', 'date_full', 'date_format', 'date_weekday', 'time', 'hour24',
|
||||
'duration', 'chart_duration', 'duration_decimal', 'money', 'currency', 'country', 'language', 'amount'
|
||||
'duration', 'chart_duration', 'chart_money', 'duration_decimal', 'money', 'currency', 'country', 'language', 'amount'
|
||||
];
|
||||
$i = 0;
|
||||
|
||||
@@ -458,11 +458,15 @@ class LocaleFormatExtensionsTest extends TestCase
|
||||
|
||||
// test extended format
|
||||
$sut = $this->getSut($this->localeFake, 'XX');
|
||||
$this->assertEquals('02 - 37 - 17 Zeit', $sut->duration($record->getDuration()));
|
||||
$this->assertEquals('02 - 37 Zeit', $sut->duration($record->getDuration()));
|
||||
|
||||
// test negative duration
|
||||
$sut = $this->getSut($this->localeEn, 'en');
|
||||
$this->assertEquals('?', $sut->duration(-1));
|
||||
$this->assertEquals('00:00 h', $sut->duration(0));
|
||||
$this->assertEquals('00:00 h', $sut->duration(-1));
|
||||
$this->assertEquals('00:00 h', $sut->duration(-59));
|
||||
$this->assertEquals('-00:01 h', $sut->duration(-60));
|
||||
$this->assertEquals('-01:36 h', $sut->duration(-5786));
|
||||
|
||||
// test zero duration
|
||||
$sut = $this->getSut($this->localeEn, 'en');
|
||||
@@ -584,4 +588,20 @@ class LocaleFormatExtensionsTest extends TestCase
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
public function testChartMoney()
|
||||
{
|
||||
$sut = $this->getSut('en', $this->localeEn, false);
|
||||
$this->assertEquals('-123456.78', $sut->moneyChart(-123456.78));
|
||||
$this->assertEquals('123456.78', $sut->moneyChart(123456.78));
|
||||
$this->assertEquals('123456.00', $sut->moneyChart(123456));
|
||||
$this->assertEquals('456.00', $sut->moneyChart(456));
|
||||
}
|
||||
|
||||
public function testCharDuration()
|
||||
{
|
||||
$sut = $this->getSut('en', $this->localeEn, false);
|
||||
$this->assertEquals('34.29', $sut->durationChart(123456.78));
|
||||
$this->assertEquals('-34.29', $sut->durationChart(-123456.78));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ class DurationTest extends TestCase
|
||||
|
||||
$this->assertNull($sut->format(null));
|
||||
$this->assertEquals('02:38', $sut->format(9494));
|
||||
$this->assertEquals('02:38:14', $sut->format(9494, Duration::FORMAT_WITH_SECONDS));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user