diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff99d51..6e9ec63a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-182](https://github.com/itk-dev/economics/pull/182) + 2597: Added invoicing rate report * [PR-184](https://github.com/itk-dev/economics/pull/184) 3489: Workload report period averages. * [PR-183](https://github.com/itk-dev/economics/pull/183) diff --git a/assets/controllers/table_highlight_controller.js b/assets/controllers/table_highlight_controller.js new file mode 100644 index 00000000..864ef837 --- /dev/null +++ b/assets/controllers/table_highlight_controller.js @@ -0,0 +1,71 @@ +import { Controller } from "@hotwired/stimulus"; + +/** + * Table highlight controller. + * + * Highlights the `` and first cell (`` or ``) of the row when + * hovering over a cell. + */ +export default class extends Controller { + connect() { + this.element.addEventListener( + "mouseenter", + (event) => this.highlight(event), + true, + ); + this.element.addEventListener( + "mouseleave", + (event) => this.clearHighlights(event), + true, + ); + } + + /** + * Handles the highlighting of the appropriate `` column and row + * header. + * + * @param {MouseEvent} event + */ + highlight(event) { + // Only run if hovering a `` (and not child elements like or ) + if (event.target.tagName !== "TD") return; + + const cell = event.target; // The actual hovered cell + + // Find the index of the hovered cell (column index) + const cellIndex = Array.from(cell.parentNode.children).indexOf(cell); + + // Highlight the corresponding column header () 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..ab939e22 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -454,5 +454,17 @@ @apply text-right; max-width: 200px; } + .double-number-container { + span { + max-width: 47px; + } + } + .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/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/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/Entity/Issue.php b/src/Entity/Issue.php index b3f61c11..6f722275 100644 --- a/src/Entity/Issue.php +++ b/src/Entity/Issue.php @@ -182,49 +182,49 @@ public function setEpicName(?string $epicName): self } /** - * @return Collection + * @return Collection */ - public function getEpics(): Collection + public function getVersions(): Collection { - return $this->epics; + return $this->versions; } - public function addEpic(Epic $epic): self + public function addVersion(Version $version): self { - if (!$this->epics->contains($epic)) { - $this->epics->add($epic); + if (!$this->versions->contains($version)) { + $this->versions->add($version); } return $this; } - public function removeEpic(Epic $epic): self + public function removeVersion(Version $version): self { - $this->epics->removeElement($epic); + $this->versions->removeElement($version); return $this; } /** - * @return Collection + * @return Collection */ - public function getVersions(): Collection + public function getEpics(): Collection { - return $this->versions; + return $this->epics; } - public function addVersion(Version $version): self + public function addEpic(Epic $epic): self { - if (!$this->versions->contains($version)) { - $this->versions->add($version); + if (!$this->epics->contains($epic)) { + $this->epics->add($epic); } return $this; } - public function removeVersion(Version $version): self + public function removeEpic(Epic $epic): self { - $this->versions->removeElement($version); + $this->epics->removeElement($epic); 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 index 4763b881..0e0e295c 100644 --- a/src/Enum/NonBillableEpicsEnum.php +++ b/src/Enum/NonBillableEpicsEnum.php @@ -2,10 +2,6 @@ namespace App\Enum; -/* - * Kind is a term on a worklog in Leantime: - * https://github.com/Leantime/leantime/blob/80c4542e19692e423820bd9030907070d281571e/app/Domain/Timesheets/Services/Timesheets.php#L22 - * */ enum NonBillableEpicsEnum: string { case UB = 'UB'; 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/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/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/Service/InvoicingRateReportService.php b/src/Service/InvoicingRateReportService.php new file mode 100644 index 00000000..34a6615b --- /dev/null +++ b/src/Service/InvoicingRateReportService.php @@ -0,0 +1,261 @@ +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, + ]); + + // 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/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..7b6a5584 --- /dev/null +++ b/templates/reports/invoicing_rate_report.html.twig @@ -0,0 +1,180 @@ +
+ + + + + + {% for periodNumeric, period in data.period %} + + {% endfor %} + + + + + {% for worker in data.workers %} + + + + + + + {% for periodNumeric, periodData in worker.dataByPeriod %} + + {% endfor %} + + + + {% for key, projectArray in worker.projectData %} + {% for key, projects in projectArray %} + {% for projectName, issues in projects %} + + + + {% for periodNumeric, periodData in worker.dataByPeriod %} + + {% endfor %} + + {% for issueName, issueDetails in issues %} + + {% if not (issueName matches '/^\\d+$/') %} + + + + {% for periodNumeric, periodData in worker.dataByPeriod %} + + {% endfor %} + + {% endif %} + + {% endfor %} + + {% endfor %} + {% endfor %} + {% endfor %} + + + {% endfor %} + + + + + {% for periodNumeric, period in data.period %} + + {% endfor %} + + + +
{{ 'workload_report.worker'|trans }} + {{ 'workload_report.workload'|trans }} + + + {% if data.viewmode == 'week' %} + {{ 'workload_report.week'|trans }} {{ period }} + {% else %} + {{ period }} + {% endif %} + + {{ 'workload_report.average'|trans }} {{ include('components/icons.html.twig', {icon: 'info', class: 'w-5 h-5'}) }} +
+
+ + {{ worker.name ?? worker.email }} + + + +
+
+ {{ worker.workload }} + +
+ {{ periodData.loggedBillableHours }} + {{ periodData.loggedBilledPercentage|round(1) ~ '%' }} +
+ +
+ {{ worker.average|round(1) ~ '%' }} +
+
+
+ + {{ projectName }} + +
+ + {% if data.includeIssues %} + + {% endif %} +
+
- + {% if issues[periodNumeric] is defined %} +
+ {{ issues[periodNumeric].loggedBillableHours ?? '0' }} + {{ issues[periodNumeric].loggedBilledHours ?? '0' }} +
+ {% endif %} +
+
+ + {{ issueName }} + {% for key, link in issueDetails.linkToissue %} + #{{ key }} + {% endfor %} + +
+ +
- + {% if issueDetails[periodNumeric] is defined %} +
+ {{ issueDetails[periodNumeric].loggedBillableHours ?? '0' }} + {{ issueDetails[periodNumeric].loggedBilledHours ?? '0' }} +
+ {% endif %} +
+ {{ 'workload_report.average'|trans }} + + ~ {{ data.periodAverages[periodNumeric]|round(1) ~ '%' }} + {{ data.totalAverage|round(1) ~ '%' }}
+
+ {{ 'workload_report.hidden-entries'|trans }}: + +
+
+
+ {{ 'invoicing_rate_report.reading-guide-header'|trans }}: +

{{ 'invoicing_rate_report.reading-guide'|trans|nl2br|raw }}

+
diff --git a/templates/worker/index.html.twig b/templates/worker/index.html.twig index 7b383917..a64619e1 100644 --- a/templates/worker/index.html.twig +++ b/templates/worker/index.html.twig @@ -17,6 +17,7 @@ {{ 'worker.email'|trans }} {{ 'worker.name'|trans }} {{ 'worker.workload'|trans }} + {{ 'worker.include_in_reports.title'|trans }} {{ 'worker.actions'|trans }} @@ -27,7 +28,7 @@ {{ worker.email }} {{ worker.name }} {{ worker.workload }} - + {{ worker.includeInReports ? 'worker.include_in_reports.yes'|trans : 'worker.include_in_reports.no'|trans }}
{{ 'worker.action_edit'|trans }} diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 4466de95..dccf2aaa 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -61,6 +61,7 @@ navigation: workload_report: "Normtidsrapport" worker: "Medarbejdere" subscription: "Abonnementer" + invoicing_rate_report: "Udfaktureringsrapport" planning: title: 'Planlægning' @@ -555,7 +556,7 @@ workload_report: workload: 'Normtid' logged_hours: 'Loggede timer' select_viewmode: 'Vælg visningstype' - select_view_period_type: 'Vælg periodetype' + select_view_period_type: 'Vælg periode' week: 'Uge' workers: 'Medarbejdere' worker: 'Medarbejder' @@ -564,6 +565,26 @@ workload_report: average: "Gennemsnit" average_describe: "Gennemsnit beregnet til og med forrige uge/måned" +invoicing_rate_report: + title: 'Udfaktureringsrapport' + year: 'Vælg år' + select_view_period_type: 'Vælg periode' + include_issues: 'Inkludér to-dos (+10s load)' + reading-guide-header: 'Læsevejledning' + submit: 'Hent udfaktureringsrapport' + reading-guide: ' + Aflæsning af todelt celle (medarbejder): + [ Fakturerbare timer for perioden ---- Procent af totalt loggede timer faktureret for perioden ]. + + Aflæsning af todelt celle (projekt og to-do): + [ Fakturerbare timer for perioden ---- Fakturerede timer for perioden ]. + + + Gennemsnit for periode: Procentvist gennemsnit af fakturerede timer af totalt tid logget. + + Gennemsnit for medarbejder: Procentvist gennemsnit af fakturerede timer af totalt tid logget.' + + hour_report: title: 'Timerapport' data_provider: 'Datakilde' @@ -736,6 +757,10 @@ worker: name: 'Navn' email: 'Email' workload: 'Normtid (inkl. middagspause)' + include_in_reports: + title: 'Inkludér i rapporter' + yes: 'Ja' + no: 'Nej' actions: 'Handlinger' action_edit: 'Rediger' edit: 'Rediger medarbejder' @@ -759,6 +784,10 @@ workload_report_view_mode_enum: billed: label: 'Fakturerede timer' +invoicing_rate_report_view_mode_enum: + summary: + label: 'Opsummering' + subscription: title: 'Dine abonnementer' subject: 'Rapporttype'