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:
Kevin Papst
2022-07-13 17:21:36 +02:00
committed by GitHub
parent 245dd47082
commit 0dba83d8e9
26 changed files with 400 additions and 80 deletions

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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;
}
}

View File

@@ -209,6 +209,7 @@ class TimesheetCountedStatistic implements \JsonSerializable
/**
* @param float $recordInternalRate
* @return $this
* @deprecated since 1.15 use setInternalRate() instead
*/
public function setRecordInternalRate($recordInternalRate)
{

View File

@@ -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']);
}
}
// ---------------------------------------------------

View File

@@ -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

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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

View File

@@ -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);
}
/**

View File

@@ -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);
}