Compare commits
3 Commits
webhooks
...
activity-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d5cef1a2f | ||
|
|
52a9ff9860 | ||
|
|
ecded0a065 |
@@ -104,7 +104,7 @@ kimai:
|
||||
BILLABLE: ['edit_billable_own_timesheet','edit_billable_other_timesheet']
|
||||
TEAMS: ['view_team','create_team','edit_team','delete_team']
|
||||
LOCKDOWN: ['lockdown_grace_timesheet','lockdown_override_timesheet']
|
||||
REPORTING: ['view_reporting','view_other_reporting','project_reporting','customer_reporting']
|
||||
REPORTING: ['view_reporting','view_other_reporting','activity_reporting','project_reporting','customer_reporting']
|
||||
EVERYONE: ['api_access','hours_own_profile']
|
||||
# permissions which are deactivated, as these features are hidden for now
|
||||
# brave users can try to activate them and be surprised what happens
|
||||
|
||||
@@ -10,24 +10,34 @@
|
||||
namespace App\Activity;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Event\ActivityBudgetStatisticEvent;
|
||||
use App\Event\ActivityStatisticEvent;
|
||||
use App\Model\ActivityBudgetStatisticModel;
|
||||
use App\Model\ActivityStatistic;
|
||||
use App\Reporting\ActivityView\ActivityViewModel;
|
||||
use App\Reporting\ActivityView\ActivityViewQuery;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\Loader\ActivityLoader;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* @final
|
||||
*/
|
||||
class ActivityStatisticService
|
||||
{
|
||||
public function __construct(private readonly TimesheetRepository $timesheetRepository, private readonly EventDispatcherInterface $dispatcher)
|
||||
public function __construct(
|
||||
private readonly TimesheetRepository $timesheetRepository,
|
||||
private readonly ActivityRepository $activityRepository,
|
||||
private readonly EventDispatcherInterface $dispatcher
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -197,4 +207,201 @@ class ActivityStatisticService
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Activity[]
|
||||
*/
|
||||
public function findActivitiesForView(ActivityViewQuery $query): array
|
||||
{
|
||||
$user = $query->getUser();
|
||||
$today = clone $query->getToday();
|
||||
|
||||
$qb = $this->activityRepository->createQueryBuilder('a');
|
||||
$qb
|
||||
->select('a')
|
||||
->leftJoin('a.project', 'p')
|
||||
->leftJoin('p.customer', 'c')
|
||||
->andWhere($qb->expr()->eq('a.visible', true))
|
||||
->andWhere(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->isNull('a.project'),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('p.visible', true),
|
||||
$qb->expr()->eq('c.visible', true),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->isNull('p.end'),
|
||||
$qb->expr()->gte('p.end', ':project_end')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
->addGroupBy('a')
|
||||
->setParameter('project_end', $today, Types::DATETIME_MUTABLE)
|
||||
;
|
||||
|
||||
if ($query->getProject() !== null) {
|
||||
$qb->andWhere($qb->expr()->eq('p', ':project'));
|
||||
$qb->setParameter('project', $query->getProject()->getId());
|
||||
}
|
||||
|
||||
if (!$query->isIncludeNoWork()) {
|
||||
$qb
|
||||
->leftJoin(Timesheet::class, 't', 'WITH', 'a.id = t.activity')
|
||||
->andHaving($qb->expr()->gt('SUM(t.duration)', 0))
|
||||
;
|
||||
}
|
||||
|
||||
if ($query->isIncludeWithBudget()) {
|
||||
$qb->andWhere(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->gt('a.timeBudget', 0),
|
||||
$qb->expr()->gt('a.budget', 0)
|
||||
)
|
||||
);
|
||||
} elseif ($query->isIncludeWithoutBudget()) {
|
||||
$qb->andWhere(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('a.timeBudget', 0),
|
||||
$qb->expr()->eq('a.budget', 0)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$this->activityRepository->addPermissionCriteria($qb, $user);
|
||||
|
||||
/** @var Activity[] $activities */
|
||||
$activities = $qb->getQuery()->getResult();
|
||||
|
||||
// pre-cache project objects instead of joining them
|
||||
$loader = new ActivityLoader($this->activityRepository->createQueryBuilder('a')->getEntityManager());
|
||||
$loader->loadResults($activities);
|
||||
|
||||
return $activities;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Activity[] $activities
|
||||
* @return ActivityViewModel[]
|
||||
*/
|
||||
public function getActivityView(User $user, array $activities, DateTimeInterface $today): array
|
||||
{
|
||||
$factory = DateTimeFactory::createByUser($user);
|
||||
$today = clone $today;
|
||||
|
||||
$startOfWeek = $factory->getStartOfWeek($today);
|
||||
$endOfWeek = $factory->getEndOfWeek($today);
|
||||
$startMonth = (clone $startOfWeek)->modify('first day of this month');
|
||||
$endMonth = (clone $startOfWeek)->modify('last day of this month');
|
||||
|
||||
$activityView = [];
|
||||
foreach ($activities as $activity) {
|
||||
$activityView[$activity->getId()] = new ActivityViewModel($activity);
|
||||
}
|
||||
|
||||
$budgetStats = $this->getBudgetStatisticModelForActivities($activities, $today);
|
||||
foreach ($budgetStats as $model) {
|
||||
$activityView[$model->getActivity()->getId()]->setBudgetStatisticModel($model);
|
||||
}
|
||||
|
||||
$activityIds = array_keys($activityView);
|
||||
|
||||
$tplQb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
$tplQb
|
||||
->select('IDENTITY(t.activity) AS id')
|
||||
->addSelect('COUNT(t.id) as amount')
|
||||
->addSelect('COALESCE(SUM(t.duration), 0) AS duration')
|
||||
->addSelect('COALESCE(SUM(t.rate), 0) AS rate')
|
||||
->andWhere($tplQb->expr()->in('t.activity', ':activity'))
|
||||
->groupBy('t.activity')
|
||||
->setParameter('activity', $activityIds)
|
||||
;
|
||||
|
||||
$qb = clone $tplQb;
|
||||
$qb->addSelect('MAX(t.date) as lastRecord');
|
||||
|
||||
/** @var array<int, array{id: int, amount: int, duration: int, rate: float, lastRecord: string}> $result */
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
foreach ($result as $row) {
|
||||
$activityView[$row['id']]->setDurationTotal($row['duration']);
|
||||
$activityView[$row['id']]->setRateTotal($row['rate']);
|
||||
$activityView[$row['id']]->setTimesheetCounter($row['amount']);
|
||||
if ($row['lastRecord'] !== null) {
|
||||
// might be the wrong timezone
|
||||
$activityView[$row['id']]->setLastRecord($factory->createDateTime($row['lastRecord']));
|
||||
}
|
||||
}
|
||||
|
||||
// values for today
|
||||
$qb = clone $tplQb;
|
||||
$qb
|
||||
->andWhere('DATE(t.date) = :start_date')
|
||||
->setParameter('start_date', $today, Types::DATETIME_MUTABLE)
|
||||
;
|
||||
|
||||
/** @var array<int, array{id: int, amount: int, duration: int, rate: float}> $result */
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
foreach ($result as $row) {
|
||||
$activityView[$row['id']]->setDurationDay($row['duration'] ?? 0);
|
||||
}
|
||||
|
||||
// values for the current week
|
||||
$qb = clone $tplQb;
|
||||
$qb
|
||||
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
|
||||
->setParameter('start_date', $startOfWeek, Types::DATETIME_MUTABLE)
|
||||
->setParameter('end_date', $endOfWeek, Types::DATETIME_MUTABLE)
|
||||
;
|
||||
|
||||
/** @var array<int, array{id: int, amount: int, duration: int, rate: float}> $result */
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
foreach ($result as $row) {
|
||||
$activityView[$row['id']]->setDurationWeek($row['duration']);
|
||||
}
|
||||
|
||||
// values for the current month
|
||||
$qb = clone $tplQb;
|
||||
$qb
|
||||
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
|
||||
->setParameter('start_date', $startMonth, Types::DATETIME_MUTABLE)
|
||||
->setParameter('end_date', $endMonth, Types::DATETIME_MUTABLE)
|
||||
;
|
||||
|
||||
/** @var array<int, array{id: int, amount: int, duration: int, rate: float}> $result */
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
foreach ($result as $row) {
|
||||
$activityView[$row['id']]->setDurationMonth($row['duration']);
|
||||
}
|
||||
|
||||
$qb = clone $tplQb;
|
||||
$qb
|
||||
->addSelect('t.exported')
|
||||
->addSelect('t.billable')
|
||||
->addGroupBy('t.exported')
|
||||
->addGroupBy('t.billable')
|
||||
;
|
||||
|
||||
/** @var array<int, array{id: int, amount: int, duration: int, rate: float, exported: bool, billable: bool}> $result */
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
foreach ($result as $row) {
|
||||
/** @var ActivityViewModel $view */
|
||||
$view = $activityView[$row['id']];
|
||||
if ($row['billable'] === 1 && $row['exported'] === 1) {
|
||||
$view->setBillableDuration($view->getBillableDuration() + $row['duration']);
|
||||
$view->setBillableRate($view->getBillableRate() + $row['rate']);
|
||||
} elseif ($row['billable'] === 1 && $row['exported'] === 0) {
|
||||
$view->setBillableDuration($view->getBillableDuration() + $row['duration']);
|
||||
$view->setBillableRate($view->getBillableRate() + $row['rate']);
|
||||
$view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']);
|
||||
$view->setNotExportedRate($view->getNotExportedRate() + $row['rate']);
|
||||
$view->setNotBilledDuration($view->getNotBilledDuration() + $row['duration']);
|
||||
$view->setNotBilledRate($view->getNotBilledRate() + $row['rate']);
|
||||
} elseif ($row['billable'] === 0 && $row['exported'] === 0) {
|
||||
$view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']);
|
||||
$view->setNotExportedRate($view->getNotExportedRate() + $row['rate']);
|
||||
}
|
||||
// the last possible case $row['billable'] === 0 && $row['exported'] === 1 is extremely unlikely and not used
|
||||
}
|
||||
|
||||
return array_values($activityView);
|
||||
}
|
||||
}
|
||||
|
||||
57
src/Controller/Reporting/ActivityViewController.php
Normal file
57
src/Controller/Reporting/ActivityViewController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Controller\Reporting;
|
||||
|
||||
use App\Activity\ActivityStatisticService;
|
||||
use App\Controller\AbstractController;
|
||||
use App\Reporting\ActivityView\ActivityViewForm;
|
||||
use App\Reporting\ActivityView\ActivityViewQuery;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
final class ActivityViewController extends AbstractController
|
||||
{
|
||||
#[Route(path: '/activity/project_view', name: 'report_activity_view', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('report:project')]
|
||||
#[IsGranted(new Expression("is_granted('budget_any', 'activity')"))]
|
||||
public function __invoke(Request $request, ActivityStatisticService $service): Response
|
||||
{
|
||||
$dateFactory = $this->getDateTimeFactory();
|
||||
$user = $this->getUser();
|
||||
|
||||
$query = new ActivityViewQuery($dateFactory->createDateTime(), $user);
|
||||
$form = $this->createFormForGetRequest(ActivityViewForm::class, $query);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$activities = $service->findActivitiesForView($query);
|
||||
$entries = $service->getActivityView($user, $activities, $query->getToday());
|
||||
|
||||
$byCustomer = [];
|
||||
foreach ($entries as $entry) {
|
||||
$project = $entry->getActivity()->getProject();
|
||||
$key = ($project === null || $project->getId() === null ? '__EMPTY__' : $project->getId());
|
||||
if (!\array_key_exists($key, $byCustomer)) {
|
||||
$byCustomer[$key] = ['project' => $project, 'activities' => [], 'name' => $project !== null ? $project->getName() : ''];
|
||||
}
|
||||
$byCustomer[$key]['activities'][] = $entry;
|
||||
}
|
||||
|
||||
return $this->render('reporting/activity_view.html.twig', [
|
||||
'entries' => $byCustomer,
|
||||
'form' => $form->createView(),
|
||||
'report_title' => 'report_activity_view',
|
||||
'tableName' => 'activity_view_reporting',
|
||||
'now' => $dateFactory->createDateTime(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
src/Reporting/ActivityView/ActivityViewForm.php
Normal file
55
src/Reporting/ActivityView/ActivityViewForm.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Reporting\ActivityView;
|
||||
|
||||
use App\Form\Type\ProjectType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* @extends AbstractType<ActivityViewQuery>
|
||||
*/
|
||||
final class ActivityViewForm extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('project', ProjectType::class, [
|
||||
'required' => false,
|
||||
'width' => false,
|
||||
]);
|
||||
$builder->add('budgetType', ChoiceType::class, [
|
||||
'label' => false,
|
||||
'required' => false,
|
||||
'placeholder' => null,
|
||||
'expanded' => true,
|
||||
'choices' => [
|
||||
'all' => null,
|
||||
'includeWithBudget' => true,
|
||||
'includeNoBudget' => false
|
||||
]
|
||||
]);
|
||||
$builder->add('includeNoWork', CheckboxType::class, [
|
||||
'required' => false,
|
||||
'label' => 'includeNoWork',
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => ActivityViewQuery::class,
|
||||
'csrf_protection' => false,
|
||||
'method' => 'GET',
|
||||
]);
|
||||
}
|
||||
}
|
||||
181
src/Reporting/ActivityView/ActivityViewModel.php
Normal file
181
src/Reporting/ActivityView/ActivityViewModel.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Reporting\ActivityView;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Model\BudgetStatisticModelInterface;
|
||||
use DateTime;
|
||||
|
||||
final class ActivityViewModel
|
||||
{
|
||||
private int $timesheetCounter = 0;
|
||||
private int $durationDay = 0;
|
||||
private int $durationWeek = 0;
|
||||
private int $durationMonth = 0;
|
||||
private int $durationTotal = 0;
|
||||
private float $rateTotal = 0.00;
|
||||
private int $notExportedDuration = 0;
|
||||
private float $notExportedRate = 0.00;
|
||||
private int $notBilledDuration = 0;
|
||||
private float $notBilledRate = 0.00;
|
||||
private int $billableDuration = 0;
|
||||
private float $billableRate = 0.00;
|
||||
private ?DateTime $lastRecord = null;
|
||||
private ?BudgetStatisticModelInterface $budgetStatisticModel = null;
|
||||
|
||||
public function __construct(private readonly Activity $activity)
|
||||
{
|
||||
}
|
||||
|
||||
public function getActivity(): Activity
|
||||
{
|
||||
return $this->activity;
|
||||
}
|
||||
|
||||
public function getTimesheetCounter(): int
|
||||
{
|
||||
return $this->timesheetCounter;
|
||||
}
|
||||
|
||||
public function setTimesheetCounter(int $timesheetCounter): void
|
||||
{
|
||||
$this->timesheetCounter = $timesheetCounter;
|
||||
}
|
||||
|
||||
public function getDurationDay(): int
|
||||
{
|
||||
return $this->durationDay;
|
||||
}
|
||||
|
||||
public function setDurationDay(int $durationDay): void
|
||||
{
|
||||
$this->durationDay = $durationDay;
|
||||
}
|
||||
|
||||
public function getDurationWeek(): int
|
||||
{
|
||||
return $this->durationWeek;
|
||||
}
|
||||
|
||||
public function setDurationWeek(int $durationWeek): void
|
||||
{
|
||||
$this->durationWeek = $durationWeek;
|
||||
}
|
||||
|
||||
public function getDurationMonth(): int
|
||||
{
|
||||
return $this->durationMonth;
|
||||
}
|
||||
|
||||
public function setDurationMonth(int $durationMonth): void
|
||||
{
|
||||
$this->durationMonth = $durationMonth;
|
||||
}
|
||||
|
||||
public function getDurationTotal(): int
|
||||
{
|
||||
return $this->durationTotal;
|
||||
}
|
||||
|
||||
public function setDurationTotal(int $durationTotal): void
|
||||
{
|
||||
$this->durationTotal = $durationTotal;
|
||||
}
|
||||
|
||||
public function getNotExportedDuration(): int
|
||||
{
|
||||
return $this->notExportedDuration;
|
||||
}
|
||||
|
||||
public function setNotExportedDuration(int $notExportedDuration): void
|
||||
{
|
||||
$this->notExportedDuration = $notExportedDuration;
|
||||
}
|
||||
|
||||
public function getNotExportedRate(): float
|
||||
{
|
||||
return $this->notExportedRate;
|
||||
}
|
||||
|
||||
public function setNotExportedRate(float $notExportedRate): void
|
||||
{
|
||||
$this->notExportedRate = $notExportedRate;
|
||||
}
|
||||
|
||||
public function getNotBilledDuration(): int
|
||||
{
|
||||
return $this->notBilledDuration;
|
||||
}
|
||||
|
||||
public function setNotBilledDuration(int $notBilledDuration): void
|
||||
{
|
||||
$this->notBilledDuration = $notBilledDuration;
|
||||
}
|
||||
|
||||
public function getNotBilledRate(): float
|
||||
{
|
||||
return $this->notBilledRate;
|
||||
}
|
||||
|
||||
public function setNotBilledRate(float $notBilledRate): void
|
||||
{
|
||||
$this->notBilledRate = $notBilledRate;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function getRateTotal(): float
|
||||
{
|
||||
return $this->rateTotal;
|
||||
}
|
||||
|
||||
public function setRateTotal(float $rateTotal): void
|
||||
{
|
||||
$this->rateTotal = $rateTotal;
|
||||
}
|
||||
|
||||
public function getLastRecord(): ?DateTime
|
||||
{
|
||||
return $this->lastRecord;
|
||||
}
|
||||
|
||||
public function setLastRecord(DateTime $lastRecord): void
|
||||
{
|
||||
$this->lastRecord = $lastRecord;
|
||||
}
|
||||
|
||||
public function getBudgetStatisticModel(): ?BudgetStatisticModelInterface
|
||||
{
|
||||
return $this->budgetStatisticModel;
|
||||
}
|
||||
|
||||
public function setBudgetStatisticModel(BudgetStatisticModelInterface $budgetStatisticModel): void
|
||||
{
|
||||
$this->budgetStatisticModel = $budgetStatisticModel;
|
||||
}
|
||||
}
|
||||
77
src/Reporting/ActivityView/ActivityViewQuery.php
Normal file
77
src/Reporting/ActivityView/ActivityViewQuery.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Reporting\ActivityView;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Entity\User;
|
||||
|
||||
final class ActivityViewQuery
|
||||
{
|
||||
private ?Project $project = null;
|
||||
private bool $includeNoWork = false;
|
||||
private ?bool $budgetType = true;
|
||||
|
||||
public function __construct(private readonly \DateTimeInterface $today, private readonly User $user)
|
||||
{
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getBudgetType(): ?bool
|
||||
{
|
||||
return $this->budgetType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function setBudgetType(?bool $budgetType): void
|
||||
{
|
||||
$this->budgetType = $budgetType;
|
||||
}
|
||||
|
||||
public function isIncludeWithoutBudget(): bool
|
||||
{
|
||||
return $this->budgetType === false;
|
||||
}
|
||||
|
||||
public function isIncludeWithBudget(): bool
|
||||
{
|
||||
return $this->budgetType === true;
|
||||
}
|
||||
|
||||
public function isIncludeNoWork(): bool
|
||||
{
|
||||
return $this->includeNoWork;
|
||||
}
|
||||
|
||||
public function setIncludeNoWork(bool $includeNoWork): void
|
||||
{
|
||||
$this->includeNoWork = $includeNoWork;
|
||||
}
|
||||
|
||||
public function getProject(): ?Project
|
||||
{
|
||||
return $this->project;
|
||||
}
|
||||
|
||||
public function setProject(Project $project): void
|
||||
{
|
||||
$this->project = $project;
|
||||
}
|
||||
|
||||
public function getToday(): \DateTimeInterface
|
||||
{
|
||||
return $this->today;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,12 @@ final class ReportingService
|
||||
$event->addReport(new Report('yearly_users_list', 'report_yearly_users', 'report_yearly_users', 'users'));
|
||||
}
|
||||
|
||||
if ($this->security->isGranted('report:activity')) {
|
||||
if ($viewOther) {
|
||||
$event->addReport(new Report('activity_view', 'report_activity_view', 'report_activity_view', 'activity'));
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->security->isGranted('report:project')) {
|
||||
if ($this->security->isGranted('details', 'project')) {
|
||||
$event->addReport(new Report('project_details', 'report_project_details', 'report_project_details', 'project'));
|
||||
|
||||
@@ -94,7 +94,7 @@ class ActivityRepository extends EntityRepository
|
||||
/**
|
||||
* @param array<Team> $teams
|
||||
*/
|
||||
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): void
|
||||
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): void
|
||||
{
|
||||
$permissions = $this->getPermissionCriteria($qb, $user, $teams, $globalsOnly);
|
||||
if ($permissions->count() > 0) {
|
||||
|
||||
@@ -22,6 +22,7 @@ final class ReportingVoter extends Voter
|
||||
private const ALLOWED_ATTRIBUTES = [
|
||||
'report:customer',
|
||||
'report:other',
|
||||
'report:activity',
|
||||
'report:project',
|
||||
'report:user',
|
||||
];
|
||||
@@ -65,6 +66,10 @@ final class ReportingVoter extends Voter
|
||||
$permissions[] = 'view_other_timesheet';
|
||||
break;
|
||||
|
||||
case 'report:activity':
|
||||
$permissions[] = 'activity_reporting';
|
||||
break;
|
||||
|
||||
case 'report:project':
|
||||
$permissions[] = 'project_reporting';
|
||||
break;
|
||||
|
||||
96
templates/reporting/activity_list_data.html.twig
Normal file
96
templates/reporting/activity_list_data.html.twig
Normal file
@@ -0,0 +1,96 @@
|
||||
{% extends 'reporting/layout.html.twig' %}
|
||||
{% import "macros/datatables.html.twig" as tables %}
|
||||
|
||||
{% set availableColumns = {
|
||||
'name': {'class': 'alwaysVisible'},
|
||||
} %}
|
||||
{% if is_granted('budget_time', 'activity') %}
|
||||
{% set availableColumns = availableColumns|merge({
|
||||
'timeBudget': {'class': 'd-none d-md-table-cell', 'title': 'timeBudget'|trans},
|
||||
}) %}
|
||||
{% endif %}
|
||||
{% if is_granted('budget_money', 'activity') %}
|
||||
{% set availableColumns = availableColumns|merge({
|
||||
'budget': {'class': 'd-none d-md-table-cell', 'title': 'budget'|trans},
|
||||
}) %}
|
||||
{% endif %}
|
||||
{% set availableColumns = availableColumns|merge({
|
||||
'durationTotal': {'class': 'text-end hw-min w-min', 'title': 'stats.durationTotal'|trans, 'columnClass': 'w-min'},
|
||||
'actions': {'class': 'actions alwaysVisible'},
|
||||
}) %}
|
||||
{% set tableName = tableName|default('activity_view_reporting') %}
|
||||
{% set skipColumns = skipColumns is defined ? skipColumns : {} %}
|
||||
{% set columns = {} %}
|
||||
{% for name, config in availableColumns %}
|
||||
{% if name not in skipColumns %}
|
||||
{% set columns = columns|merge({(name): config}) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% block main_before %}
|
||||
{{ tables.data_table_column_modal(tableName, columns) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block report %}
|
||||
|
||||
{% set hasData = entries|length > 0 %}
|
||||
|
||||
{% embed '@theme/embeds/card.html.twig' %}
|
||||
{% import "macros/progressbar.html.twig" as progress %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% import "macros/datatables.html.twig" as tables %}
|
||||
{% import "activity/actions.html.twig" as activityActions %}
|
||||
{% block box_body_class %}{{ tableName }}-box {% if hasData %}p-0{% endif %}{% endblock %}
|
||||
{% block box_body %}
|
||||
{% if not hasData %}
|
||||
{{ widgets.nothing_found() }}
|
||||
{% else %}
|
||||
{{ tables.datatable_header(tableName, columns, null, {boxClass: ''}) }}
|
||||
|
||||
{% for id, mapping in entries|sort((a, b) => a.name <=> b.name) %}
|
||||
{% if mapping.project is not null %}
|
||||
<tr class="summary">
|
||||
<td colspan="{{ columns|length }}">
|
||||
{{ widgets.label_customer(mapping.project) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for entry in mapping.activities|sort((a, b) => a.activity.name <=> b.activity.name) %}
|
||||
{% set activity = entry.activity %}
|
||||
{% set project = activity.project %}
|
||||
{% set budgetStats = entry.getBudgetStatisticModel() %}
|
||||
{% set currency = null %}
|
||||
{% if project is not null %}
|
||||
{% set currency = project.customer.currency %}
|
||||
{% endif %}
|
||||
<tr {{ widgets.activity_row_attr(activity, now) }}>
|
||||
{% for name, column_config in columns %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, name) }}">
|
||||
{% if name == 'name' %}
|
||||
{{ widgets.label_activity(activity) }}
|
||||
{% elseif name == 'durationTotal' %}
|
||||
{{ entry.durationTotal|duration }}
|
||||
{% elseif name == 'timeBudget' %}
|
||||
{% if budgetStats.hasTimeBudget() and is_granted('time', activity) %}
|
||||
{{ progress.progressbar_timebudget(budgetStats) }}
|
||||
{% endif %}
|
||||
{% elseif name == 'budget' %}
|
||||
{% if activity.hasBudget() and is_granted('budget', activity) %}
|
||||
{{ progress.progressbar_budget(budgetStats, currency) }}
|
||||
{% endif %}
|
||||
{% elseif name == 'comment' %}
|
||||
{{ activity.comment }}
|
||||
{% elseif name == 'actions' %}
|
||||
{{ activityActions.activity(activity, 'custom') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{{ tables.data_table_footer(entries) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
{% endblock %}
|
||||
21
templates/reporting/activity_view.html.twig
Normal file
21
templates/reporting/activity_view.html.twig
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends 'reporting/activity_list_data.html.twig' %}
|
||||
|
||||
{% block report_form_layout %}
|
||||
{{ form_widget(form.project, {'label': false, 'placeholder': 'please_choose'}) }}
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
{{ icon('filter', true) }}
|
||||
</button>
|
||||
<ul class="dropdown-menu checkbox-menu">
|
||||
{% for option in form.budgetType.children %}
|
||||
<li class="dropdown-item">
|
||||
{{ form_widget(option) }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="dropdown-divider"></li>
|
||||
<li class="dropdown-item">
|
||||
{{ form_widget(form.includeNoWork) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -26,6 +26,10 @@
|
||||
<source>report_yearly_users</source>
|
||||
<target>Jahresansicht für alle Benutzer</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="7.486Fn" resname="report_activity_view">
|
||||
<source>report_activity_view</source>
|
||||
<target>Tätigkeitsübersicht</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="R.9yUkf" resname="report_project_view">
|
||||
<source>report_project_view</source>
|
||||
<target>Projektübersicht</target>
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
<source>report_yearly_users</source>
|
||||
<target>Yearly view for all users</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="7.486Fn" resname="report_activity_view">
|
||||
<source>report_activity_view</source>
|
||||
<target>Activity overview</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="R.9yUkf" resname="report_project_view">
|
||||
<source>report_project_view</source>
|
||||
<target>Project overview</target>
|
||||
|
||||
Reference in New Issue
Block a user