) in the ``
+ const columnHeader = this.element.querySelector(
+ `thead th:nth-child(${cellIndex + 1})`,
+ );
+ if (columnHeader) columnHeader.classList.add("highlight-column");
+
+ // Highlight the first cell in the row (supports both
and
)
+ const rowStartCell = cell.parentNode.querySelector(
+ "th:first-child, td:first-child",
+ );
+ if (rowStartCell) rowStartCell.classList.add("highlight-row");
+ }
+
+ /**
+ * Clears all highlights when leaving a cell.
+ *
+ * @param {MouseEvent} event
+ */
+ clearHighlights(event) {
+ // Only run if leaving a `
` (and not child elements)
+ if (event.target.tagName !== "TD") return;
+
+ // Remove the highlight class from all highlighted elements
+ this.element.querySelectorAll(".highlight-column").forEach((header) => {
+ header.classList.remove("highlight-column");
+ });
+
+ this.element
+ .querySelectorAll(".highlight-row")
+ .forEach((rowStartCell) => {
+ rowStartCell.classList.remove("highlight-row");
+ });
+ }
+}
diff --git a/assets/controllers/toggle-parent-child_controller.js b/assets/controllers/toggle-parent-child_controller.js
index ddb24aa6..bf21e53a 100644
--- a/assets/controllers/toggle-parent-child_controller.js
+++ b/assets/controllers/toggle-parent-child_controller.js
@@ -12,13 +12,9 @@ export default class extends Controller {
displayChildrenForParentIds = [];
- svgExpand = ``;
+ svgExpand = ``;
- svgCollapse = ``;
+ svgCollapse = ``;
connect() {
this.parentTargets.forEach((target) => {
diff --git a/assets/fontawesome.js b/assets/fontawesome.js
index bf9d683b..1e3e9ed5 100644
--- a/assets/fontawesome.js
+++ b/assets/fontawesome.js
@@ -6,7 +6,9 @@ import {
faMaximize,
faEyeSlash,
faMinimize,
+ faCaretRight,
+ faCaretDown,
} from "@fortawesome/free-solid-svg-icons";
-library.add(faMaximize, faEyeSlash, faMinimize);
+library.add(faMaximize, faEyeSlash, faMinimize, faCaretRight, faCaretDown);
dom.i2svg();
diff --git a/assets/styles/app.css b/assets/styles/app.css
index 2f06c22c..54867238 100644
--- a/assets/styles/app.css
+++ b/assets/styles/app.css
@@ -454,5 +454,20 @@
@apply text-right;
max-width: 200px;
}
+ .double-number-container {
+ span {
+ max-width: 47px;
+ }
+ span:last-child {
+ cursor: help;
+ }
+ }
+ .highlight-column, .highlight-row {
+ border: 1px solid #fff !important;
+ }
+ tr[data-toggle-parent-child-target="child"]:not(.hidden) + tr[data-toggle-parent-child-target="parent"] > td,
+ tr[data-toggle-parent-child-target="child"]:not(.hidden) + tr[data-toggle-parent-child-target="parent"] > th {
+ border-top: 1px solid #fff;
+ }
}
diff --git a/migrations/Version20250102102102.php b/migrations/Version20250102102102.php
new file mode 100644
index 00000000..60ed4b58
--- /dev/null
+++ b/migrations/Version20250102102102.php
@@ -0,0 +1,39 @@
+addSql('CREATE TABLE epic (id INT AUTO_INCREMENT NOT NULL, title VARCHAR(255) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
+ $this->addSql('CREATE TABLE issue_epic (issue_id INT NOT NULL, epic_id INT NOT NULL, INDEX IDX_412E98BD5E7AA58C (issue_id), INDEX IDX_412E98BD6B71E00E (epic_id), PRIMARY KEY(issue_id, epic_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
+ $this->addSql('ALTER TABLE issue_epic ADD CONSTRAINT FK_412E98BD5E7AA58C FOREIGN KEY (issue_id) REFERENCES issue (id) ON DELETE CASCADE');
+ $this->addSql('ALTER TABLE issue_epic ADD CONSTRAINT FK_412E98BD6B71E00E FOREIGN KEY (epic_id) REFERENCES epic (id) ON DELETE CASCADE');
+ $this->addSql('ALTER TABLE version ADD is_billable TINYINT(1) NOT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql('ALTER TABLE issue_epic DROP FOREIGN KEY FK_412E98BD5E7AA58C');
+ $this->addSql('ALTER TABLE issue_epic DROP FOREIGN KEY FK_412E98BD6B71E00E');
+ $this->addSql('DROP TABLE epic');
+ $this->addSql('DROP TABLE issue_epic');
+ $this->addSql('ALTER TABLE version DROP is_billable');
+ }
+}
diff --git a/migrations/Version20250102120303.php b/migrations/Version20250102120303.php
new file mode 100644
index 00000000..79ec2568
--- /dev/null
+++ b/migrations/Version20250102120303.php
@@ -0,0 +1,31 @@
+addSql('ALTER TABLE worker ADD include_in_reports TINYINT(1) DEFAULT 1 NOT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql('ALTER TABLE worker DROP include_in_reports');
+ }
+}
diff --git a/src/Command/MigrateEpicsCommand.php b/src/Command/MigrateEpicsCommand.php
new file mode 100644
index 00000000..f119547e
--- /dev/null
+++ b/src/Command/MigrateEpicsCommand.php
@@ -0,0 +1,42 @@
+dataSynchronizationService->migrateEpics(function ($i, $length) use ($io) {
+ if (0 == $i) {
+ $io->progressStart($length);
+ } elseif ($i >= $length - 1) {
+ $io->progressFinish();
+ } else {
+ $io->progressAdvance();
+ }
+ });
+
+ $io->success('All issues have been processed successfully.');
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Controller/InvoicingRateReportController.php b/src/Controller/InvoicingRateReportController.php
new file mode 100644
index 00000000..fc016468
--- /dev/null
+++ b/src/Controller/InvoicingRateReportController.php
@@ -0,0 +1,72 @@
+createForm(InvoicingRateReportType::class, $reportFormData, [
+ 'action' => $this->generateUrl('app_invoicing_rate_report'),
+ 'method' => 'GET',
+ 'attr' => [
+ 'id' => 'sprint_report',
+ ],
+ 'years' => [
+ (new \DateTime())->modify('-1 year')->format('Y'),
+ (new \DateTime())->format('Y'),
+ ],
+ 'csrf_protection' => false,
+ ]);
+
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ $viewPeriodType = $form->get('viewPeriodType')->getData() ?? PeriodTypeEnum::WEEK;
+ $viewMode = InvoicingRateReportViewModeEnum::SUMMARY;
+ $year = $form->get('year')->getData();
+ $includeIssues = $form->get('includeIssues')->getData();
+
+ try {
+ $reportData = $this->invoicingRateReportService->getInvoicingRateReport($year, $viewPeriodType, $viewMode, $includeIssues);
+ } catch (\Exception $e) {
+ $error = $e->getMessage();
+ }
+ }
+
+ return $this->render('reports/reports.html.twig', [
+ 'controller_name' => 'InvoicingRateReportController',
+ 'form' => $form,
+ 'error' => $error,
+ 'data' => $reportData,
+ 'mode' => $mode,
+ ]);
+ }
+}
diff --git a/src/Controller/PlanningController.php b/src/Controller/PlanningController.php
index a8115680..39a0670f 100644
--- a/src/Controller/PlanningController.php
+++ b/src/Controller/PlanningController.php
@@ -32,7 +32,11 @@ private function preparePlanningData(Request $request): array
'attr' => [
'id' => 'sprint_report',
],
- 'years' => [(new \DateTime())->format('Y'), (new \DateTime())->modify('+1 year')->format('Y')],
+ 'years' => [
+ (new \DateTime())->modify('-1 year')->format('Y'),
+ (new \DateTime())->format('Y'),
+ (new \DateTime())->modify('+1 year')->format('Y'),
+ ],
'method' => 'GET',
'csrf_protection' => false,
]);
diff --git a/src/Controller/WorkloadReportController.php b/src/Controller/WorkloadReportController.php
index afe58179..23bda7b1 100644
--- a/src/Controller/WorkloadReportController.php
+++ b/src/Controller/WorkloadReportController.php
@@ -6,7 +6,6 @@
use App\Model\Reports\WorkloadReportFormData;
use App\Model\Reports\WorkloadReportPeriodTypeEnum as PeriodTypeEnum;
use App\Model\Reports\WorkloadReportViewModeEnum as ViewModeEnum;
-use App\Repository\DataProviderRepository;
use App\Service\WorkloadReportService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -19,11 +18,13 @@
class WorkloadReportController extends AbstractController
{
public function __construct(
- private readonly DataProviderRepository $dataProviderRepository,
private readonly WorkloadReportService $workloadReportService,
) {
}
+ /**
+ * @throws \DateMalformedStringException
+ */
#[Route('/', name: 'app_workload_report')]
public function index(Request $request): Response
{
@@ -38,28 +39,24 @@ public function index(Request $request): Response
'attr' => [
'id' => 'sprint_report',
],
+ 'years' => [
+ (new \DateTime())->modify('-1 year')->format('Y'),
+ (new \DateTime())->format('Y'),
+ ],
'csrf_protection' => false,
]);
$form->handleRequest($request);
- $requestData = $request->query->all('workload_report');
-
- if (!empty($requestData['dataProvider'])) {
- $dataProvider = $this->dataProviderRepository->find($requestData['dataProvider']);
-
- if ($form->isSubmitted() && $form->isValid()) {
- $selectedDataProvider = $form->get('dataProvider')->getData() ?? $dataProvider;
- $viewPeriodType = $form->get('viewPeriodType')->getData() ?? PeriodTypeEnum::WEEK;
- $viewMode = $form->get('viewMode')->getData() ?? ViewModeEnum::WORKLOAD;
+ if ($form->isSubmitted() && $form->isValid()) {
+ $viewPeriodType = $form->get('viewPeriodType')->getData() ?? PeriodTypeEnum::WEEK;
+ $viewMode = $form->get('viewMode')->getData() ?? ViewModeEnum::WORKLOAD;
+ $year = $form->get('year')->getData();
- if ($selectedDataProvider) {
- try {
- $reportData = $this->workloadReportService->getWorkloadReport($viewPeriodType, $viewMode);
- } catch (\Exception $e) {
- $error = $e->getMessage();
- }
- }
+ try {
+ $reportData = $this->workloadReportService->getWorkloadReport($year, $viewPeriodType, $viewMode);
+ } catch (\Exception $e) {
+ $error = $e->getMessage();
}
}
diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php
index 78959d6e..f295f201 100644
--- a/src/DataFixtures/AppFixtures.php
+++ b/src/DataFixtures/AppFixtures.php
@@ -4,6 +4,7 @@
use App\Entity\Client;
use App\Entity\DataProvider;
+use App\Entity\Epic;
use App\Entity\Issue;
use App\Entity\Project;
use App\Entity\Version;
@@ -51,6 +52,10 @@ public function load(ObjectManager $manager): void
$workerArray[] = 'test'.$i.'@test';
}
+ $epic = new Epic();
+ $epic->setTitle('Epic 1');
+ $manager->persist($epic);
+
foreach ($dataProviders as $key => $dataProvider) {
$manager->persist($dataProvider);
@@ -113,8 +118,8 @@ public function load(ObjectManager $manager): void
$issue->setProjectTrackerId("issue-$i-$j");
$issue->setAccountId('Account 1');
$issue->setAccountKey('Account 1');
- $issue->setEpicName('Epic 1');
- $issue->setEpicKey('Epic 1');
+ $issue->setEpicName('Epic '.$j % 2 .(1 == $key && 0 == $j % 5 ? ',More than one Epic' : ''));
+ $issue->setEpicKey('Epic '.$j % 2 .(1 == $key && 0 == $j % 5 ? ',More than one Epic' : ''));
$issue->setStatus($modStatus);
$issue->setDataProvider($dataProvider);
$issue->addVersion($versions[$j % count($versions)]);
@@ -125,9 +130,12 @@ public function load(ObjectManager $manager): void
$issue->setDueDate(new \DateTime());
$issue->setWorker($workerArray[rand(0, 9)]);
$issue->setLinkToIssue('www.example.com');
-
$manager->persist($issue);
+ if (0 == $key && 0 == $i && 0 == $j) {
+ $issue->addEpic($epic);
+ }
+
for ($k = 0; $k < 100; ++$k) {
$year = (new \DateTime())->format('Y');
diff --git a/src/Entity/Epic.php b/src/Entity/Epic.php
new file mode 100644
index 00000000..d711986d
--- /dev/null
+++ b/src/Entity/Epic.php
@@ -0,0 +1,68 @@
+issues = new ArrayCollection();
+ }
+ #[ORM\Id]
+ #[ORM\GeneratedValue]
+ #[ORM\Column]
+ private ?int $id = null;
+
+ #[ORM\Column(length: 255)]
+ private ?string $title = null;
+
+ #[ORM\ManyToMany(targetEntity: Issue::class, mappedBy: 'epics')]
+ private Collection $issues;
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getTitle(): ?string
+ {
+ return $this->title;
+ }
+
+ public function setTitle(string $title): static
+ {
+ $this->title = $title;
+
+ return $this;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getIssues(): Collection
+ {
+ return $this->issues;
+ }
+
+ public function addIssue(Issue $issue): self
+ {
+ if (!$this->issues->contains($issue)) {
+ $this->issues->add($issue);
+ }
+
+ return $this;
+ }
+
+ public function removeIssue(Issue $issues): self
+ {
+ $this->issues->removeElement($issues);
+
+ return $this;
+ }
+}
diff --git a/src/Entity/Issue.php b/src/Entity/Issue.php
index 80a556ce..6f722275 100644
--- a/src/Entity/Issue.php
+++ b/src/Entity/Issue.php
@@ -43,6 +43,9 @@ class Issue extends AbstractBaseEntity
#[ORM\Column(length: 255, nullable: true)]
private ?string $epicName = null;
+ #[ORM\ManyToMany(targetEntity: Epic::class, inversedBy: 'issues')]
+ private Collection $epics;
+
#[ORM\ManyToMany(targetEntity: Version::class, inversedBy: 'issues')]
private Collection $versions;
@@ -79,6 +82,7 @@ public function __construct()
$this->versions = new ArrayCollection();
$this->worklogs = new ArrayCollection();
$this->products = new ArrayCollection();
+ $this->epics = new ArrayCollection();
}
public function getName(): ?string
@@ -201,6 +205,30 @@ public function removeVersion(Version $version): self
return $this;
}
+ /**
+ * @return Collection
+ */
+ public function getEpics(): Collection
+ {
+ return $this->epics;
+ }
+
+ public function addEpic(Epic $epic): self
+ {
+ if (!$this->epics->contains($epic)) {
+ $this->epics->add($epic);
+ }
+
+ return $this;
+ }
+
+ public function removeEpic(Epic $epic): self
+ {
+ $this->epics->removeElement($epic);
+
+ return $this;
+ }
+
/**
* @return Collection
*/
diff --git a/src/Entity/Version.php b/src/Entity/Version.php
index 825b9d89..78c2d103 100644
--- a/src/Entity/Version.php
+++ b/src/Entity/Version.php
@@ -29,6 +29,9 @@ class Version extends AbstractBaseEntity
#[ORM\ManyToMany(targetEntity: Issue::class, mappedBy: 'versions')]
private Collection $issues;
+ #[ORM\Column]
+ private ?bool $isBillable = true;
+
public function __construct()
{
$this->issues = new ArrayCollection();
@@ -101,4 +104,16 @@ public function removeIssue(Issue $issue): self
return $this;
}
+
+ public function isBillable(): ?bool
+ {
+ return $this->isBillable;
+ }
+
+ public function setIsBillable(bool $isBillable): static
+ {
+ $this->isBillable = $isBillable;
+
+ return $this;
+ }
}
diff --git a/src/Entity/Worker.php b/src/Entity/Worker.php
index 8d6aa3c1..31d9c846 100644
--- a/src/Entity/Worker.php
+++ b/src/Entity/Worker.php
@@ -23,6 +23,9 @@ class Worker
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
+ #[ORM\Column(nullable: false, options: ['default' => true])]
+ private bool $includeInReports = true;
+
public function __construct()
{
}
@@ -77,4 +80,16 @@ public function setName(?string $name): static
return $this;
}
+
+ public function getIncludeInReports(): bool
+ {
+ return $this->includeInReports;
+ }
+
+ public function setIncludeInReports(bool $includeInReports): self
+ {
+ $this->includeInReports = $includeInReports;
+
+ return $this;
+ }
}
diff --git a/src/Enum/NonBillableEpicsEnum.php b/src/Enum/NonBillableEpicsEnum.php
new file mode 100644
index 00000000..0e0e295c
--- /dev/null
+++ b/src/Enum/NonBillableEpicsEnum.php
@@ -0,0 +1,20 @@
+
+ */
+ public static function getAsArray(): array
+ {
+ return array_reduce(
+ self::cases(),
+ static fn (array $choices, NonBillableEpicsEnum $type) => $choices + [$type->name => $type->value],
+ [],
+ );
+ }
+}
diff --git a/src/Enum/NonBillableVersionsEnum.php b/src/Enum/NonBillableVersionsEnum.php
new file mode 100644
index 00000000..4d8eba31
--- /dev/null
+++ b/src/Enum/NonBillableVersionsEnum.php
@@ -0,0 +1,20 @@
+
+ */
+ public static function getAsArray(): array
+ {
+ return array_reduce(
+ self::cases(),
+ static fn (array $choices, NonBillableVersionsEnum $type) => $choices + [$type->name => $type->value],
+ [],
+ );
+ }
+}
diff --git a/src/Form/InvoicingRateReportType.php b/src/Form/InvoicingRateReportType.php
new file mode 100644
index 00000000..0e8b0954
--- /dev/null
+++ b/src/Form/InvoicingRateReportType.php
@@ -0,0 +1,81 @@
+add('year', ChoiceType::class, [
+ 'label' => 'invoicing_rate_report.year',
+ 'label_attr' => ['class' => 'label'],
+ 'attr' => ['class' => 'form-element '],
+ 'help_attr' => ['class' => 'form-help'],
+ 'row_attr' => ['class' => 'form-row'],
+ 'required' => false,
+ 'data' => $yearChoices[date('Y')],
+ 'choices' => $yearChoices,
+ 'placeholder' => null,
+ ])
+ ->add('viewPeriodType', EnumType::class, [
+ 'required' => false,
+ 'label' => 'invoicing_rate_report.select_view_period_type',
+ 'label_attr' => ['class' => 'label'],
+ 'placeholder' => false,
+ 'attr' => [
+ 'class' => 'form-element',
+ ],
+ 'class' => PeriodTypeEnum::class,
+ ])
+ ->add('includeIssues', ChoiceType::class, [
+ 'choices' => [
+ 'Ja' => true,
+ 'Nej' => false,
+ ],
+ 'required' => false,
+ 'placeholder' => false,
+ 'label' => 'invoicing_rate_report.include_issues',
+ 'label_attr' => ['class' => 'checkbox-label'],
+ 'attr' => [
+ 'class' => 'form-element',
+ ],
+ 'data' => false,
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => 'invoicing_rate_report.submit',
+ 'attr' => [
+ 'class' => 'hour-report-submit button',
+ ],
+ ]);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => InvoicingRateReportFormData::class,
+ 'attr' => [
+ 'data-sprint-report-target' => 'form',
+ ],
+ 'years' => null,
+ ]);
+ }
+}
diff --git a/src/Form/WorkerType.php b/src/Form/WorkerType.php
index b7f2cb67..7905fd9b 100644
--- a/src/Form/WorkerType.php
+++ b/src/Form/WorkerType.php
@@ -4,6 +4,7 @@
use App\Entity\Worker;
use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -37,6 +38,20 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
'required' => false,
'row_attr' => ['class' => 'form-row'],
])
+ ->add('includeInReports', ChoiceType::class, [
+ 'label' => 'worker.include_in_reports.title',
+ 'label_attr' => ['class' => 'label'],
+ 'attr' => ['class' => 'form-element'],
+ 'help_attr' => ['class' => 'form-help'],
+ 'required' => false,
+ 'row_attr' => ['class' => 'form-row'],
+ 'choices' => [
+ 'worker.include_in_reports.yes' => true,
+ 'worker.include_in_reports.no' => false,
+ ],
+ 'multiple' => false,
+ 'placeholder' => false,
+ ])
;
}
diff --git a/src/Form/WorkloadReportType.php b/src/Form/WorkloadReportType.php
index 48e70a62..955978b4 100644
--- a/src/Form/WorkloadReportType.php
+++ b/src/Form/WorkloadReportType.php
@@ -2,13 +2,11 @@
namespace App\Form;
-use App\Entity\DataProvider;
use App\Model\Reports\WorkloadReportFormData;
use App\Model\Reports\WorkloadReportPeriodTypeEnum as PeriodTypeEnum;
use App\Model\Reports\WorkloadReportViewModeEnum;
-use App\Repository\DataProviderRepository;
-use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -17,31 +15,26 @@
class WorkloadReportType extends AbstractType
{
public function __construct(
- private readonly DataProviderRepository $dataProviderRepository,
- private readonly ?string $defaultDataProvider,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
- $dataProviders = $this->dataProviderRepository->findAll();
- $defaultProvider = $this->dataProviderRepository->find($this->defaultDataProvider);
-
- if (null === $defaultProvider && count($dataProviders) > 0) {
- $defaultProvider = $dataProviders[0];
+ $yearChoices = [];
+ foreach ($options['years'] as $year) {
+ $yearChoices[$year] = $year;
}
-
$builder
- ->add('dataProvider', EntityType::class, [
- 'class' => DataProvider::class,
- 'required' => false,
- 'label' => 'workload_report.select_data_provider',
+ ->add('year', ChoiceType::class, [
+ 'label' => 'invoicing_rate_report.year',
'label_attr' => ['class' => 'label'],
- 'attr' => [
- 'class' => 'form-element',
- ],
- 'data' => $defaultProvider,
- 'choices' => $dataProviders,
+ 'attr' => ['class' => 'form-element '],
+ 'help_attr' => ['class' => 'form-help'],
+ 'row_attr' => ['class' => 'form-row'],
+ 'required' => false,
+ 'data' => $yearChoices[date('Y')],
+ 'choices' => $yearChoices,
+ 'placeholder' => null,
])
->add('viewMode', EnumType::class, [
'required' => false,
@@ -78,6 +71,7 @@ public function configureOptions(OptionsResolver $resolver): void
'attr' => [
'data-sprint-report-target' => 'form',
],
+ 'years' => null,
]);
}
}
diff --git a/src/Model/Invoices/IssueData.php b/src/Model/Invoices/IssueData.php
index f9a2eefb..00ec5f2b 100644
--- a/src/Model/Invoices/IssueData.php
+++ b/src/Model/Invoices/IssueData.php
@@ -17,8 +17,9 @@ class IssueData
public ?string $accountKey = null;
public ?string $epicName = null;
public ?string $epicKey = null;
+ public array $epics;
/** @var Collection */
- public ?Collection $versions;
+ public Collection $versions;
public ?\DateTime $resolutionDate = null;
public string $projectId;
public ?int $planHours;
diff --git a/src/Model/Reports/InvoicingRateReportData.php b/src/Model/Reports/InvoicingRateReportData.php
new file mode 100644
index 00000000..2fab660f
--- /dev/null
+++ b/src/Model/Reports/InvoicingRateReportData.php
@@ -0,0 +1,46 @@
+ */
+ public ArrayCollection $period;
+ /** @var ArrayCollection */
+ public ArrayCollection $workers;
+ public int $currentPeriodNumeric;
+ public ArrayCollection $periodAverages;
+ public float $totalAverage;
+ /**
+ * @var bool
+ */
+ public bool $includeIssues;
+
+ public function __construct(string $viewmode)
+ {
+ $this->viewmode = $viewmode;
+ $this->period = new ArrayCollection();
+ $this->workers = new ArrayCollection();
+ $this->periodAverages = new ArrayCollection();
+ $this->includeIssues = false;
+ $this->totalAverage = 0;
+ }
+
+ /**
+ * Set current week.
+ *
+ * @param int $currentPeriodNumeric
+ *
+ * @return self
+ */
+ public function setCurrentPeriodNumeric(int $currentPeriodNumeric): self
+ {
+ $this->currentPeriodNumeric = $currentPeriodNumeric;
+
+ return $this;
+ }
+}
diff --git a/src/Model/Reports/InvoicingRateReportFormData.php b/src/Model/Reports/InvoicingRateReportFormData.php
new file mode 100644
index 00000000..e1933c78
--- /dev/null
+++ b/src/Model/Reports/InvoicingRateReportFormData.php
@@ -0,0 +1,10 @@
+ $translator->trans('invoicing_rate_report_view_mode_enum.summary.label', locale: $locale),
+ };
+ }
+}
diff --git a/src/Model/Reports/InvoicingRateReportWorker.php b/src/Model/Reports/InvoicingRateReportWorker.php
new file mode 100644
index 00000000..033fac64
--- /dev/null
+++ b/src/Model/Reports/InvoicingRateReportWorker.php
@@ -0,0 +1,82 @@
+ */
+ public ArrayCollection $dataByPeriod;
+
+ /** @var ArrayCollection */
+ public ArrayCollection $projectData;
+
+ public function __construct(Worker $worker)
+ {
+ $this->worker = $worker;
+ $this->average = 0.0;
+ $this->dataByPeriod = new ArrayCollection();
+ $this->projectData = new ArrayCollection();
+ }
+
+ public function getWorker(): Worker
+ {
+ return $this->worker;
+ }
+
+ // Proxy methods to access Worker fields
+ public function getEmail(): ?string
+ {
+ return $this->worker->getEmail();
+ }
+
+ public function setEmail(string $email): self
+ {
+ $this->worker->setEmail($email);
+
+ return $this;
+ }
+
+ public function getWorkload(): ?float
+ {
+ return $this->worker->getWorkload();
+ }
+
+ public function setWorkload(?float $workload): self
+ {
+ $this->worker->setWorkload($workload);
+
+ return $this;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->worker->getName();
+ }
+
+ public function setName(?string $name): self
+ {
+ $this->worker->setName($name);
+
+ return $this;
+ }
+
+ public function getIncludeInReports(): bool
+ {
+ return $this->worker->getIncludeInReports();
+ }
+
+ public function setIncludeInReports(bool $includeInReports): self
+ {
+ $this->worker->setIncludeInReports($includeInReports);
+
+ return $this;
+ }
+}
diff --git a/src/Model/Reports/WorkloadReportData.php b/src/Model/Reports/WorkloadReportData.php
index 267899c7..9ab57c69 100644
--- a/src/Model/Reports/WorkloadReportData.php
+++ b/src/Model/Reports/WorkloadReportData.php
@@ -13,12 +13,16 @@ class WorkloadReportData
/** @var ArrayCollection */
public ArrayCollection $workers;
public int $currentPeriodNumeric;
+ public ArrayCollection $periodAverages;
+ public float $totalAverage;
public function __construct(string $viewmode)
{
$this->viewmode = $viewmode;
$this->period = new ArrayCollection();
$this->workers = new ArrayCollection();
+ $this->periodAverages = new ArrayCollection();
+ $this->totalAverage = 0;
}
/**
diff --git a/src/Model/Reports/WorkloadReportFormData.php b/src/Model/Reports/WorkloadReportFormData.php
index 95f156fe..6d1c8374 100644
--- a/src/Model/Reports/WorkloadReportFormData.php
+++ b/src/Model/Reports/WorkloadReportFormData.php
@@ -2,11 +2,9 @@
namespace App\Model\Reports;
-use App\Entity\DataProvider;
-
class WorkloadReportFormData
{
- public DataProvider $dataProvider;
+ public int $year;
public WorkloadReportPeriodTypeEnum $viewPeriodType;
public WorkloadReportViewModeEnum $viewMode;
}
diff --git a/src/Model/Reports/WorkloadReportViewModeEnum.php b/src/Model/Reports/WorkloadReportViewModeEnum.php
index 85cb0b06..f290ac09 100644
--- a/src/Model/Reports/WorkloadReportViewModeEnum.php
+++ b/src/Model/Reports/WorkloadReportViewModeEnum.php
@@ -7,14 +7,16 @@
enum WorkloadReportViewModeEnum: string implements TranslatableInterface
{
- case WORKLOAD = 'workload_percentage_logged';
- case BILLABLE = 'billable_percentage_logged';
+ case WORKLOAD = 'workload';
+ case BILLABLE = 'billable';
+ case BILLED = 'billed';
public function trans(TranslatorInterface $translator, ?string $locale = null): string
{
return match ($this) {
self::WORKLOAD => $translator->trans('workload_report_view_mode_enum.workload.label', locale: $locale),
self::BILLABLE => $translator->trans('workload_report_view_mode_enum.billable.label', locale: $locale),
+ self::BILLED => $translator->trans('workload_report_view_mode_enum.billed.label', locale: $locale),
};
}
}
diff --git a/src/Repository/EpicRepository.php b/src/Repository/EpicRepository.php
new file mode 100644
index 00000000..e3e3dc5c
--- /dev/null
+++ b/src/Repository/EpicRepository.php
@@ -0,0 +1,41 @@
+
+ *
+ * @method Epic|null find($id, $lockMode = null, $lockVersion = null)
+ * @method Epic|null findOneBy(array $criteria, array $orderBy = null)
+ * @method Epic[] findAll()
+ * @method Epic[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+class EpicRepository extends ServiceEntityRepository
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, Epic::class);
+ }
+
+ public function save(Epic $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->persist($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function remove(Epic $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->remove($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+}
diff --git a/src/Repository/WorkerRepository.php b/src/Repository/WorkerRepository.php
index ef6a7d34..ca5c47f5 100644
--- a/src/Repository/WorkerRepository.php
+++ b/src/Repository/WorkerRepository.php
@@ -38,4 +38,12 @@ public function remove(Worker $entity, bool $flush = false): void
$this->getEntityManager()->flush();
}
}
+
+ public function findAllIncludedInReports(): array
+ {
+ return $this->createQueryBuilder('w')
+ ->where('w.includeInReports = true')
+ ->getQuery()
+ ->getResult();
+ }
}
diff --git a/src/Repository/WorklogRepository.php b/src/Repository/WorklogRepository.php
index d108ddaf..76160a0b 100644
--- a/src/Repository/WorklogRepository.php
+++ b/src/Repository/WorklogRepository.php
@@ -6,7 +6,8 @@
use App\Entity\Issue;
use App\Entity\Project;
use App\Entity\Worklog;
-use App\Enum\BillableKindsEnum;
+use App\Enum\NonBillableEpicsEnum;
+use App\Enum\NonBillableVersionsEnum;
use App\Model\Invoices\InvoiceEntryWorklogsFilterData;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Tools\Pagination\Paginator;
@@ -114,23 +115,74 @@ public function findWorklogsByWorkerAndDateRange(string $workerIdentifier, \Date
public function findBillableWorklogsByWorkerAndDateRange(string $workerIdentifier, \DateTime $dateFrom, \DateTime $dateTo)
{
+ $nonBillableEpics = NonBillableEpicsEnum::getAsArray();
+ $nonBillableVersions = NonBillableVersionsEnum::getAsArray();
+
$qb = $this->createQueryBuilder('worklog');
- $qb->leftJoin(Project::class, 'project', 'WITH', 'project.id = worklog.project');
+ $qb->leftJoin(Project::class, 'project', 'WITH', 'project.id = worklog.project')
+ ->leftJoin('worklog.issue', 'issue')
+ ->leftJoin('issue.epics', 'epic')
+ ->leftJoin('issue.versions', 'version');
return $qb
->where($qb->expr()->between('worklog.started', ':dateFrom', ':dateTo'))
->andWhere('worklog.worker = :worker')
- ->andWhere($qb->expr()->in('worklog.kind', ':billableKinds'))
+ ->andWhere($qb->expr()->andX(
+ $qb->expr()->eq('project.isBillable', '1'),
+ ))
+ // notIn will only work if the string it is checked against is not null
->andWhere($qb->expr()->orX(
+ $qb->expr()->isNull('epic.title'),
+ $qb->expr()->notIn('epic.title', ':nonBillableEpics'),
+ ))
+ ->andWhere($qb->expr()->orX(
+ $qb->expr()->isNull('version.name'),
+ $qb->expr()->notIn('version.name', ':nonBillableVersions')
+ ))
+ ->setParameters([
+ 'worker' => $workerIdentifier,
+ 'dateFrom' => $dateFrom,
+ 'dateTo' => $dateTo,
+ 'nonBillableEpics' => array_values($nonBillableEpics),
+ 'nonBillableVersions' => array_values($nonBillableVersions),
+ ])
+ ->getQuery()->getResult();
+ }
+
+ public function findBilledWorklogsByWorkerAndDateRange(string $workerIdentifier, \DateTime $dateFrom, \DateTime $dateTo)
+ {
+ $nonBillableEpics = NonBillableEpicsEnum::getAsArray();
+ $nonBillableVersions = NonBillableVersionsEnum::getAsArray();
+
+ $qb = $this->createQueryBuilder('worklog');
+
+ $qb->leftJoin(Project::class, 'project', 'WITH', 'project.id = worklog.project')
+ ->leftJoin('worklog.issue', 'issue')
+ ->leftJoin('issue.epics', 'epic')
+ ->leftJoin('issue.versions', 'version');
+
+ return $qb
+ ->where($qb->expr()->between('worklog.started', ':dateFrom', ':dateTo'))
+ ->andWhere('worklog.worker = :worker')
+ ->andWhere($qb->expr()->andX(
$qb->expr()->eq('worklog.isBilled', '1'),
- $qb->expr()->eq('project.isBillable', '1'),
+ ))
+ // notIn will only work if the string it is checked against is not null
+ ->andWhere($qb->expr()->orX(
+ $qb->expr()->isNull('epic.title'),
+ $qb->expr()->notIn('epic.title', ':nonBillableEpics'),
+ ))
+ ->andWhere($qb->expr()->orX(
+ $qb->expr()->isNull('version.name'),
+ $qb->expr()->notIn('version.name', ':nonBillableVersions')
))
->setParameters([
'worker' => $workerIdentifier,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
- 'billableKinds' => array_values(BillableKindsEnum::getAsArray()),
+ 'nonBillableEpics' => array_values($nonBillableEpics),
+ 'nonBillableVersions' => array_values($nonBillableVersions),
])
->getQuery()->getResult();
}
diff --git a/src/Service/DataProviderService.php b/src/Service/DataProviderService.php
index 32e4d363..91cf80a0 100644
--- a/src/Service/DataProviderService.php
+++ b/src/Service/DataProviderService.php
@@ -83,7 +83,7 @@ public function getService(DataProvider $dataProvider): DataProviderServiceInter
$this->weekGoalLow,
$this->weekGoalHigh,
$this->sprintNameRegex,
- $this->dateTimeHelper
+ $this->dateTimeHelper,
);
break;
default:
diff --git a/src/Service/DataSynchronizationService.php b/src/Service/DataSynchronizationService.php
index 1d5c53b9..1540b088 100644
--- a/src/Service/DataSynchronizationService.php
+++ b/src/Service/DataSynchronizationService.php
@@ -5,6 +5,7 @@
use App\Entity\Account;
use App\Entity\Client;
use App\Entity\DataProvider;
+use App\Entity\Epic;
use App\Entity\Invoice;
use App\Entity\Issue;
use App\Entity\Project;
@@ -18,6 +19,7 @@
use App\Repository\AccountRepository;
use App\Repository\ClientRepository;
use App\Repository\DataProviderRepository;
+use App\Repository\EpicRepository;
use App\Repository\InvoiceRepository;
use App\Repository\IssueRepository;
use App\Repository\ProjectRepository;
@@ -45,6 +47,7 @@ public function __construct(
private readonly DataProviderService $dataProviderService,
private readonly DataProviderRepository $dataProviderRepository,
private readonly WorkerRepository $workerRepository,
+ private readonly EpicRepository $epicRepository,
) {
}
@@ -271,6 +274,22 @@ public function syncIssuesForProject(int $projectId, ?callable $progressCallback
}
}
+ foreach ($issueDatum->epics as $epicTitle) {
+ if (empty($epicTitle)) {
+ continue;
+ }
+ $epic = $this->epicRepository->findOneBy(['title' => $epicTitle]);
+
+ if (null === $epic) {
+ $epic = new Epic();
+ $epic->setTitle($epicTitle);
+ $this->entityManager->persist($epic);
+ $this->entityManager->flush();
+ }
+
+ $issue->addEpic($epic);
+ }
+
if (null !== $progressCallback) {
$progressCallback($issuesProcessed, $total);
++$issuesProcessed;
@@ -459,4 +478,76 @@ public function migrateCustomers(): void
$this->entityManager->flush();
}
+
+ /**
+ * Migrate from issue.epicName to issue.epics.
+ *
+ * @param callable|null $progressCallback
+ *
+ * @return void
+ */
+ public function migrateEpics(?callable $progressCallback = null): void
+ {
+ // Get all issues
+ $issues = $this->issueRepository->findAll();
+
+ if (!$issues) {
+ if (null !== $progressCallback) {
+ $progressCallback(0, 0);
+ }
+ }
+
+ $issuesProcessed = 0;
+
+ foreach ($issues as $issue) {
+ $existingEpicNames = $issue->getEpics()->reduce(function (array $carry, Epic $epic) {
+ $epicName = $epic->getTitle();
+ if (null !== $epicName && !in_array($epicName, $carry)) {
+ $carry[] = $epic->getTitle();
+ }
+
+ return $carry;
+ }, []);
+
+ $epicNameArray = [];
+
+ if (LeantimeApiService::class === $issue->getDataProvider()?->getClass()) {
+ $epicNameArray = explode(',', $issue->getEpicName() ?? '');
+ } elseif (!empty($issue->getEpicName())) {
+ $epicNameArray[] = $issue->getEpicName();
+ }
+
+ foreach ($epicNameArray as $epicName) {
+ if (empty($epicName)) {
+ continue;
+ }
+
+ $epicName = trim($epicName);
+
+ if (in_array($epicName, $existingEpicNames, true)) {
+ continue;
+ }
+
+ $epic = $this->epicRepository->findOneBy(['title' => $epicName]);
+
+ if (null == $epic) {
+ // Create a new Epic if it doesn't exist
+ $epic = new Epic();
+ $epic->setTitle($epicName);
+ $this->epicRepository->save($epic, true);
+ }
+
+ // Assign the Epic to the Issue
+ $issue->addEpic($epic);
+ }
+
+ if (null !== $progressCallback) {
+ $progressCallback($issuesProcessed, count($issues));
+ ++$issuesProcessed;
+ }
+ }
+
+ // Save changes to the database
+ $this->entityManager->flush();
+ }
}
diff --git a/src/Service/InvoicingRateReportService.php b/src/Service/InvoicingRateReportService.php
new file mode 100644
index 00000000..1ebd8b29
--- /dev/null
+++ b/src/Service/InvoicingRateReportService.php
@@ -0,0 +1,262 @@
+value);
+ $invoicingRateReportData->includeIssues = $includeIssues;
+ if (!$year) {
+ $year = (int) (new \DateTime())->format('Y');
+ }
+ $workers = $this->workerRepository->findAllIncludedInReports();
+ $periods = $this->getPeriods($viewPeriodType, $year);
+ $periodSums = [];
+ $periodCounts = [];
+
+ foreach ($periods as $period) {
+ $readablePeriod = $this->getReadablePeriod($period, $viewPeriodType);
+ $invoicingRateReportData->period->set((string) $period, $readablePeriod);
+ }
+
+ foreach ($workers as $worker) {
+ $invoicingRateReportWorker = new InvoicingRateReportWorker($worker);
+ $invoicingRateReportWorker->setEmail($worker->getUserIdentifier());
+ $invoicingRateReportWorker->setWorkload($worker->getWorkload());
+ $invoicingRateReportWorker->setName($worker->getName());
+ $currentPeriodReached = false;
+ $loggedBilledHoursSum = 0;
+ $loggedHoursSum = 0;
+ $workerProjects = [];
+
+ foreach ($periods as $period) {
+ // Add current period match-point (current week-number, month-number etc.)
+ if ($year !== (int) date('Y')) {
+ /*
+ Since the sums used to calculate averages are summed up until the current period,
+ when showing a year not current, we want to make sure that we include all the periods, hence ([the number of periods] + 1).
+ */
+ $currentPeriodNumeric = count($periods) + 1;
+ } else {
+ $currentPeriodNumeric = $this->getCurrentPeriodNumeric($viewPeriodType);
+ }
+
+ if ($period === $currentPeriodNumeric) {
+ $invoicingRateReportData->setCurrentPeriodNumeric($period);
+ $currentPeriodReached = true;
+ }
+
+ // Get first and last date in period.
+ ['dateFrom' => $dateFrom, 'dateTo' => $dateTo] = $this->getDatesOfPeriod($period, $year, $viewPeriodType);
+ $workerIdentifier = $worker->getUserIdentifier();
+
+ if (empty($workerIdentifier)) {
+ throw new \Exception('Worker identifier cannot be empty');
+ }
+
+ [$worklogs, $billableWorklogs, $billedWorklogs] = $this->getWorklogs($viewMode, $workerIdentifier, $dateFrom, $dateTo);
+
+ // Tally up logged hours in gathered worklogs for current period
+ $loggedHours = 0;
+ foreach ($worklogs as $worklog) {
+ $loggedHours += ($worklog->getTimeSpentSeconds() * self::SECONDS_TO_HOURS);
+ }
+
+ // Tally up billable logged hours in gathered worklogs for current period
+ $loggedBillableHours = 0;
+ foreach ($billableWorklogs as $billableWorklog) {
+ $projectName = $billableWorklog->getProject()->getName();
+ $issueName = $billableWorklog->getIssue()->getName();
+ $workerProjects[$projectName][$period]['loggedBillableHours'] = ($workerProjects[$projectName][$period]['loggedBillableHours'] ?? 0) + ($billableWorklog->getTimeSpentSeconds() * self::SECONDS_TO_HOURS);
+ if ($includeIssues) {
+ $workerProjects[$projectName][$issueName][$period]['loggedBillableHours'] = ($workerProjects[$projectName][$issueName][$period]['loggedBillableHours'] ?? 0) + ($billableWorklog->getTimeSpentSeconds() * self::SECONDS_TO_HOURS);
+ $workerProjects[$projectName][$issueName]['linkToissue'][$billableWorklog->getIssue()->getProjectTrackerId()] = $billableWorklog->getIssue()->getLinkToIssue();
+ }
+ $loggedBillableHours += ($billableWorklog->getTimeSpentSeconds() * self::SECONDS_TO_HOURS);
+ }
+
+ // Tally up billed logged hours in gathered worklogs for current period
+ $loggedBilledHours = 0;
+ foreach ($billedWorklogs as $billedWorklog) {
+ $projectName = $billedWorklog->getProject()->getName();
+ $issueName = $billedWorklog->getIssue()->getName();
+ $workerProjects[$projectName][$period]['loggedBilledHours'] = ($workerProjects[$projectName][$period]['loggedBilledHours'] ?? 0) + ($billedWorklog->getTimeSpentSeconds() * self::SECONDS_TO_HOURS);
+ if ($includeIssues) {
+ $workerProjects[$projectName][$issueName][$period]['loggedBilledHours'] = ($workerProjects[$projectName][$issueName][$period]['loggedBilledHours'] ?? 0) + ($billedWorklog->getTimeSpentSeconds() * self::SECONDS_TO_HOURS);
+ $workerProjects[$projectName][$issueName]['linkToissue'][$billedWorklog->getIssue()->getProjectTrackerId()] = $billedWorklog->getIssue()->getLinkToIssue();
+ }
+ $loggedBilledHours += ($billedWorklog->getTimeSpentSeconds() * self::SECONDS_TO_HOURS);
+ }
+
+ // Count up sums until current period have been reached.
+ if (!$currentPeriodReached) {
+ $loggedBilledHoursSum += $loggedBilledHours;
+ $loggedHoursSum += $loggedHours;
+ }
+
+ $loggedBilledPercentage = $loggedHours > 0 ? round($loggedBilledHours / $loggedHours * 100, 4) : 0;
+
+ // Add percentage result to worker for current period.
+ $invoicingRateReportWorker->dataByPeriod->set($period, [
+ 'loggedBillableHours' => $loggedBillableHours,
+ 'loggedBilledPercentage' => $loggedBilledPercentage,
+ 'totalLoggedHours' => $loggedBilledHours.' / '.$loggedHours,
+ ]);
+
+ // Increment the sum and count for this period
+ $periodSums[$period] = ($periodSums[$period] ?? 0) + $loggedBilledPercentage;
+ $periodCounts[$period] = ($periodCounts[$period] ?? 0) + 1;
+
+ // Calculate and set the average for this period
+ $average = round($periodSums[$period] / $periodCounts[$period], 4);
+
+ $invoicingRateReportData->periodAverages->set($period, $average);
+ }
+
+ $invoicingRateReportWorker->average = $loggedHoursSum > 0 ? round($loggedBilledHoursSum / $loggedHoursSum * 100, 4) : 0;
+
+ $invoicingRateReportData->workers->add($invoicingRateReportWorker);
+
+ $invoicingRateReportWorker->projectData->set('projects', [
+ $workerProjects,
+ ]);
+ }
+
+ // Calculate and set the total average
+ $numberOfPeriods = count($invoicingRateReportData->periodAverages);
+
+ // Calculate the sum of period averages
+ $averageSum = array_reduce($invoicingRateReportData->periodAverages->toArray(), function ($carry, $item) {
+ return $carry + $item;
+ }, 0);
+
+ // Calculate the total average of averages
+ if ($numberOfPeriods > 0) {
+ $invoicingRateReportData->totalAverage = round($averageSum / $numberOfPeriods, 4);
+ }
+
+ return $invoicingRateReportData;
+ }
+
+ /**
+ * Retrieves the current period as a numeric value based on the given view mode.
+ *
+ * @param PeriodTypeEnum $viewMode the view mode to determine the current period
+ *
+ * @return int the current period as a numeric value
+ */
+ private function getCurrentPeriodNumeric(PeriodTypeEnum $viewMode): int
+ {
+ return match ($viewMode) {
+ PeriodTypeEnum::MONTH => (int) (new \DateTime())->format('n'),
+ PeriodTypeEnum::WEEK => (int) (new \DateTime())->format('W'),
+ PeriodTypeEnum::YEAR => (int) (new \DateTime())->format('Y'),
+ };
+ }
+
+ /**
+ * Retrieves an array of dates for a given period based on the view mode.
+ *
+ * @param int $period the period for which to retrieve dates
+ * @param int $year the year for the period
+ * @param PeriodTypeEnum $viewMode the view mode to determine the dates of the period
+ *
+ * @return array an array of dates for the given period
+ */
+ private function getDatesOfPeriod(int $period, int $year, PeriodTypeEnum $viewMode): array
+ {
+ return match ($viewMode) {
+ PeriodTypeEnum::MONTH => $this->dateTimeHelper->getFirstAndLastDateOfMonth($period, $year),
+ PeriodTypeEnum::WEEK => $this->dateTimeHelper->getFirstAndLastDateOfWeek($period, $year),
+ PeriodTypeEnum::YEAR => $this->dateTimeHelper->getFirstAndLastDateOfYear($year),
+ };
+ }
+
+ /**
+ * Retrieves the readable period based on the given period and view mode.
+ *
+ * @param int $period the period to be made readable
+ * @param PeriodTypeEnum $viewMode the view mode to determine the format of the readable period
+ *
+ * @return string the readable period
+ */
+ private function getReadablePeriod(int $period, PeriodTypeEnum $viewMode): string
+ {
+ return match ($viewMode) {
+ PeriodTypeEnum::MONTH => $this->dateTimeHelper->getMonthName($period),
+ PeriodTypeEnum::WEEK, PeriodTypeEnum::YEAR => (string) $period,
+ };
+ }
+
+ /**
+ * Retrieves an array of periods based on the given view mode.
+ *
+ * @param PeriodTypeEnum $viewMode the view mode to determine the periods
+ * @param int $year the year containing the periods
+ *
+ * @return array an array of periods
+ */
+ private function getPeriods(PeriodTypeEnum $viewMode, int $year): array
+ {
+ return match ($viewMode) {
+ PeriodTypeEnum::MONTH => range(1, 12),
+ PeriodTypeEnum::WEEK => $this->dateTimeHelper->getWeeksOfYear($year),
+ PeriodTypeEnum::YEAR => [(int) (new \DateTime())->format('Y')],
+ };
+ }
+
+ /**
+ * Returns workloads based on the provided view mode, worker, and date range.
+ *
+ * @param InvoicingRateReportViewModeEnum $viewMode defines the view mode
+ * @param string $workerIdentifier the worker's identifier
+ * @param \DateTime $dateFrom
+ * @param \DateTime $dateTo
+ *
+ * @return array the list of workloads matching the criteria defined by the parameters
+ */
+ private function getWorklogs(InvoicingRateReportViewModeEnum $viewMode, string $workerIdentifier, \DateTime $dateFrom, \DateTime $dateTo): array
+ {
+ return match ($viewMode) {
+ InvoicingRateReportViewModeEnum::SUMMARY => [
+ $this->worklogRepository->findWorklogsByWorkerAndDateRange($workerIdentifier, $dateFrom, $dateTo),
+ $this->worklogRepository->findBillableWorklogsByWorkerAndDateRange($workerIdentifier, $dateFrom, $dateTo),
+ $this->worklogRepository->findBilledWorklogsByWorkerAndDateRange($workerIdentifier, $dateFrom, $dateTo),
+ ],
+ };
+ }
+}
diff --git a/src/Service/JiraApiService.php b/src/Service/JiraApiService.php
index 301edcf4..4a4025d4 100644
--- a/src/Service/JiraApiService.php
+++ b/src/Service/JiraApiService.php
@@ -16,10 +16,7 @@
use App\Model\Invoices\VersionData;
use App\Model\Invoices\WorklogData;
use App\Model\Invoices\WorklogDataCollection;
-use App\Model\Planning\Issue;
use App\Model\Planning\PlanningData;
-use App\Model\Planning\Project;
-use App\Model\Planning\Sprint;
use App\Model\SprintReport\SprintReportData;
use App\Model\SprintReport\SprintReportEpic;
use App\Model\SprintReport\SprintReportIssue;
@@ -737,6 +734,8 @@ public function getIssuesDataForProjectPaged(string $projectId, $startAt = 0, $m
$issueData->epicKey = $epicKey;
$issueData->epicName = $epicData->fields->summary ?? null;
+
+ $issueData->epics = null !== $issueData->epicName ? [$issueData->epicName] : [];
}
foreach ($fields->fixVersions ?? [] as $fixVersion) {
diff --git a/src/Service/LeantimeApiService.php b/src/Service/LeantimeApiService.php
index 50141060..9f1dc63a 100644
--- a/src/Service/LeantimeApiService.php
+++ b/src/Service/LeantimeApiService.php
@@ -192,6 +192,9 @@ public function getIssuesDataForProjectPaged(string $projectId, $startAt = 0, $m
$issueData->accountKey = '';
$issueData->epicKey = $issue->tags;
$issueData->epicName = $issue->tags;
+
+ $issueData->epics = explode(',', $issue->tags);
+
$issueData->planHours = $issue->planHours;
$issueData->hourRemaining = $issue->hourRemaining;
$issueData->dueDate = !empty($issue->dateToFinish) && '0000-00-00 00:00:00' !== $issue->dateToFinish ? new \DateTime($issue->dateToFinish, self::getLeantimeTimeZone()) : null;
diff --git a/src/Service/WorkloadReportService.php b/src/Service/WorkloadReportService.php
index edbc6c22..b8e4a621 100644
--- a/src/Service/WorkloadReportService.php
+++ b/src/Service/WorkloadReportService.php
@@ -19,21 +19,31 @@ public function __construct(
}
/**
- * Retrieves the workload report data for the given view mode.
+ * Generates a workload report for a specified year and period type.
*
- * @param PeriodTypeEnum $viewPeriodType The view period type (default: 'week')
- * @param ViewModeEnum $viewMode the view mode to generate the report for
+ * This method computes the workload report for all workers based on the
+ * specified year, period type, and view mode. It calculates various metrics
+ * such as logged hours, expected workload, work percentage for each period,
+ * average workloads, and overall summary statistics.
*
- * @return WorkloadReportData the workload report data
+ * @param int $year the year for which the workload report is generated
+ * @param PeriodTypeEnum $viewPeriodType the period type (e.g., week, month, year) for the report
+ * @param ViewModeEnum $viewMode the mode of viewing the workload (e.g., workload vs other modes)
*
- * @throws \Exception when the workload of a worker cannot be unset
+ * @return WorkloadReportData an object containing the workload report data
+ *
+ * @throws \Exception if a worker identifier is empty or the workload of a worker is null
*/
- public function getWorkloadReport(PeriodTypeEnum $viewPeriodType = PeriodTypeEnum::WEEK, ViewModeEnum $viewMode = ViewModeEnum::WORKLOAD): WorkloadReportData
+ public function getWorkloadReport(int $year, PeriodTypeEnum $viewPeriodType = PeriodTypeEnum::WEEK, ViewModeEnum $viewMode = ViewModeEnum::WORKLOAD): WorkloadReportData
{
$workloadReportData = new WorkloadReportData($viewPeriodType->value);
- $year = (int) (new \DateTime())->format('Y');
+ if (!$year) {
+ $year = (int) (new \DateTime())->format('Y');
+ }
$workers = $this->workerRepository->findAll();
$periods = $this->getPeriods($viewPeriodType, $year);
+ $periodSums = [];
+ $periodCounts = [];
foreach ($periods as $period) {
$readablePeriod = $this->getReadablePeriod($period, $viewPeriodType);
@@ -91,6 +101,14 @@ public function getWorkloadReport(PeriodTypeEnum $viewPeriodType = PeriodTypeEnu
// Add percentage result to worker for current period.
$workloadReportWorker->loggedPercentage->set($period, $roundedLoggedPercentage);
+
+ // Increment the sum and count for this period
+ $periodSums[$period] = ($periodSums[$period] ?? 0) + $roundedLoggedPercentage;
+ $periodCounts[$period] = ($periodCounts[$period] ?? 0) + 1;
+
+ // Calculate and set the average for this period
+ $average = round($periodSums[$period] / $periodCounts[$period], 2);
+ $workloadReportData->periodAverages->set($period, $average);
}
$workloadReportWorker->average = $expectedWorkloadSum > 0 ? round($loggedHoursSum / $expectedWorkloadSum * 100, 2) : 0;
@@ -98,6 +116,19 @@ public function getWorkloadReport(PeriodTypeEnum $viewPeriodType = PeriodTypeEnu
$workloadReportData->workers->add($workloadReportWorker);
}
+ // Calculate and set the total average
+ $numberOfPeriods = count($workloadReportData->periodAverages);
+
+ // Calculate the sum of period averages
+ $averageSum = array_reduce($workloadReportData->periodAverages->toArray(), function ($carry, $item) {
+ return $carry + $item;
+ }, 0);
+
+ // Calculate the total average of averages
+ if ($numberOfPeriods > 0) {
+ $workloadReportData->totalAverage = round($averageSum / $numberOfPeriods, 2);
+ }
+
return $workloadReportData;
}
@@ -191,6 +222,7 @@ private function getWorklogs(ViewModeEnum $viewMode, string $workerIdentifier, \
return match ($viewMode) {
ViewModeEnum::WORKLOAD => $this->worklogRepository->findWorklogsByWorkerAndDateRange($workerIdentifier, $dateFrom, $dateTo),
ViewModeEnum::BILLABLE => $this->worklogRepository->findBillableWorklogsByWorkerAndDateRange($workerIdentifier, $dateFrom, $dateTo),
+ ViewModeEnum::BILLED => $this->worklogRepository->findBilledWorklogsByWorkerAndDateRange($workerIdentifier, $dateFrom, $dateTo),
};
}
}
diff --git a/templates/components/navigation.html.twig b/templates/components/navigation.html.twig
index 076e9c8c..00d0d4af 100644
--- a/templates/components/navigation.html.twig
+++ b/templates/components/navigation.html.twig
@@ -34,6 +34,7 @@
{{ include('components/navigation-item.html.twig', {title: 'navigation.forecast_report'|trans, role: 'ROLE_REPORT', route: path('app_forecast_report')}) }}
{{ include('components/navigation-item.html.twig', {title: 'navigation.hour_report'|trans, role: 'ROLE_REPORT', route: path('app_hour_report')}) }}
{{ include('components/navigation-item.html.twig', {title: 'navigation.workload_report'|trans, role: 'ROLE_REPORT', route: path('app_workload_report')}) }}
+ {{ include('components/navigation-item.html.twig', {title: 'navigation.invoicing_rate_report'|trans, role: 'ROLE_REPORT', route: path('app_invoicing_rate_report')}) }}
{% endif %}
diff --git a/templates/reports/invoicing_rate_report.html.twig b/templates/reports/invoicing_rate_report.html.twig
new file mode 100644
index 00000000..bf1ade9a
--- /dev/null
+++ b/templates/reports/invoicing_rate_report.html.twig
@@ -0,0 +1,180 @@
+
+
+
+
+
{{ 'workload_report.worker'|trans }}
+
+ {{ 'workload_report.workload'|trans }}
+
+ {% for periodNumeric, period in data.period %}
+
+
+ {% if data.viewmode == 'week' %}
+ {{ 'workload_report.week'|trans }} {{ period }}
+ {% else %}
+ {{ period }}
+ {% endif %}
+