From d379a960a0bc66daad6bd63ca2611d1d71e47a39 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 20 Jan 2026 10:49:06 +0100 Subject: [PATCH 01/24] make:controller CybersecurityReport --- .../CybersecurityReportController.php | 21 +++++++++++++++++++ .../cybersecurity_report/index.html.twig | 20 ++++++++++++++++++ .../CybersecurityReportControllerTest.php | 16 ++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 src/Controller/CybersecurityReportController.php create mode 100644 templates/cybersecurity_report/index.html.twig create mode 100644 tests/Controller/CybersecurityReportControllerTest.php diff --git a/src/Controller/CybersecurityReportController.php b/src/Controller/CybersecurityReportController.php new file mode 100644 index 00000000..a2a4bd4c --- /dev/null +++ b/src/Controller/CybersecurityReportController.php @@ -0,0 +1,21 @@ +render('cybersecurity_report/index.html.twig', [ + 'controller_name' => 'CybersecurityReportController', + ]); + } +} diff --git a/templates/cybersecurity_report/index.html.twig b/templates/cybersecurity_report/index.html.twig new file mode 100644 index 00000000..a7e51c0a --- /dev/null +++ b/templates/cybersecurity_report/index.html.twig @@ -0,0 +1,20 @@ +{% extends 'base.html.twig' %} + +{% block title %}Hello CybersecurityReportController!{% endblock %} + +{% block body %} + + +
+

Hello {{ controller_name }}! ✅

+ + This friendly message is coming from: + +
+{% endblock %} diff --git a/tests/Controller/CybersecurityReportControllerTest.php b/tests/Controller/CybersecurityReportControllerTest.php new file mode 100644 index 00000000..52648044 --- /dev/null +++ b/tests/Controller/CybersecurityReportControllerTest.php @@ -0,0 +1,16 @@ +request('GET', '/cybersecurity/report'); + + self::assertResponseIsSuccessful(); + } +} From ef6efa2ab54ac4d0e187cbb607c1ee8025b8eb4b Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 20 Jan 2026 10:49:19 +0100 Subject: [PATCH 02/24] Added navigation entry alongside translation --- templates/components/navigation.html.twig | 1 + translations/messages.da.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/templates/components/navigation.html.twig b/templates/components/navigation.html.twig index f1fa8b43..2ef38836 100644 --- a/templates/components/navigation.html.twig +++ b/templates/components/navigation.html.twig @@ -37,6 +37,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.billable_unbilled_hours_report'|trans, role: 'ROLE_REPORT', route: path('app_billable_unbilled_hours_report')}) }} {{ include('components/navigation-item.html.twig', {title: 'navigation.invoicing_rate_report'|trans, role: 'ROLE_REPORT', route: path('app_invoicing_rate_report')}) }} + {{ include('components/navigation-item.html.twig', {title: 'navigation.cybersecurity_report'|trans, role: 'ROLE_REPORT', route: path('app_cybersecurity_report')}) }} {{ include('components/navigation-item.html.twig', {title: 'navigation.management_report'|trans, role: 'ROLE_REPORT', route: path('app_management_reports_create')}) }} {{ include('components/navigation-item.html.twig', {title: 'navigation.subscription'|trans, role: 'ROLE_REPORT', route: path('app_subscription_index')}) }} diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 09112909..f9543127 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -30,6 +30,7 @@ navigation: planning_holidays: 'Ferieplan' group: 'Grupper' serviceagreements: 'Driftsaftaler' + cybersecurity_report: 'Cybersikkerhedsrapport' planning: group: 'Vælg gruppe' From 521c9c74f97e9d52a43a9a22202f2a30fa259900 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 20 Jan 2026 12:11:47 +0100 Subject: [PATCH 03/24] Moved cybersecurity report to reports folder --- templates/reports/cybersecurity_report.html.twig | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 templates/reports/cybersecurity_report.html.twig diff --git a/templates/reports/cybersecurity_report.html.twig b/templates/reports/cybersecurity_report.html.twig new file mode 100644 index 00000000..e69de29b From dd50b4f3853fd2d210133e957233da7f1c5bafba Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 20 Jan 2026 12:12:18 +0100 Subject: [PATCH 04/24] Removed autogenerated index twig --- .../cybersecurity_report/index.html.twig | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 templates/cybersecurity_report/index.html.twig diff --git a/templates/cybersecurity_report/index.html.twig b/templates/cybersecurity_report/index.html.twig deleted file mode 100644 index a7e51c0a..00000000 --- a/templates/cybersecurity_report/index.html.twig +++ /dev/null @@ -1,20 +0,0 @@ -{% extends 'base.html.twig' %} - -{% block title %}Hello CybersecurityReportController!{% endblock %} - -{% block body %} - - -
-

Hello {{ controller_name }}! ✅

- - This friendly message is coming from: -
    -
  • Your controller at /app/src/Controller/CybersecurityReportController.php
  • -
  • Your template at /app/templates/cybersecurity_report/index.html.twig
  • -
-
-{% endblock %} From d90e0f681c80cfd8259688bd43c17f913ca9e090 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 20 Jan 2026 12:12:43 +0100 Subject: [PATCH 05/24] Added form and structure to controller --- .../CybersecurityReportController.php | 41 ++++++- src/Form/CybersecurityReportType.php | 102 ++++++++++++++++++ .../Reports/CybersecurityReportFormData.php | 14 +++ src/Service/CybersecurityReportService.php | 36 +++++++ translations/messages.da.yaml | 10 ++ 5 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 src/Form/CybersecurityReportType.php create mode 100644 src/Model/Reports/CybersecurityReportFormData.php create mode 100644 src/Service/CybersecurityReportService.php diff --git a/src/Controller/CybersecurityReportController.php b/src/Controller/CybersecurityReportController.php index a2a4bd4c..48f711c5 100644 --- a/src/Controller/CybersecurityReportController.php +++ b/src/Controller/CybersecurityReportController.php @@ -2,7 +2,11 @@ namespace App\Controller; +use App\Form\CybersecurityReportType; +use App\Model\Reports\CybersecurityReportFormData; +use App\Repository\DataProviderRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -11,11 +15,44 @@ #[IsGranted('ROLE_REPORT')] final class CybersecurityReportController extends AbstractController { + public function __construct( + private readonly DataProviderRepository $dataProviderRepository, + private readonly ?string $defaultDataProvider, + ) { + } #[Route('/', name: 'app_cybersecurity_report')] - public function index(): Response + public function index(Request $request): Response { - return $this->render('cybersecurity_report/index.html.twig', [ + $reportData = null; + $reportFormData = new CyberSecurityReportFormData(); + + $dataProvider = null; + $version = null; + + $requestData = $request->query->all('cybersecurity_report'); + + if (!empty($requestData['dataProvider'])) { + $dataProvider = $this->dataProviderRepository->find($requestData['dataProvider']); + } elseif (null !== $this->defaultDataProvider) { + $dataProvider = $this->dataProviderRepository->find($this->defaultDataProvider); + } + + $form = $this->createForm(CybersecurityReportType::class, $reportFormData, [ + // Since this is only a filtering form, csrf is not needed. + 'csrf_protection' => false, + 'action' => $this->generateUrl('app_hour_report'), + 'method' => 'GET', + 'attr' => [ + 'id' => 'hour_report', + ], + 'data_provider' => $dataProvider, + 'version' => $version, + ]); + return $this->render('reports/reports.html.twig', [ 'controller_name' => 'CybersecurityReportController', + 'form' => $form, + 'data' => $reportData, + 'mode' => 'cybersecurity_report', ]); } } diff --git a/src/Form/CybersecurityReportType.php b/src/Form/CybersecurityReportType.php new file mode 100644 index 00000000..5da03eb9 --- /dev/null +++ b/src/Form/CybersecurityReportType.php @@ -0,0 +1,102 @@ +dataProviderRepository->findAll(); + $defaultProvider = $this->dataProviderRepository->find($this->defaultDataProvider); + + if (null === $defaultProvider && count($dataProviders) > 0) { + $defaultProvider = $dataProviders[0]; + } + + $builder + ->add('dataProvider', EntityType::class, [ + 'class' => DataProvider::class, + 'required' => false, + 'label' => 'cybersecurity_report.data_provider', + 'label_attr' => ['class' => 'label'], + 'placeholder' => 'cybersecurity_report.select_data_provider', + 'attr' => [ + 'onchange' => 'this.form.submit()', + 'class' => 'form-element', + ], + 'help' => 'cybersecurity_report.data_provider_helptext', + 'data' => $defaultProvider, + 'choices' => $dataProviders, + ]) + ->add('version', ChoiceType::class, [ + 'choices' => [ + self::DEFAULT_CYBERSECURITY_MILESTONE => self::DEFAULT_CYBERSECURITY_MILESTONE, + ], + 'required' => false, + 'attr' => [ + 'class' => 'form-element', + 'onchange' => 'this.form.submit()', + ], + 'row_attr' => ['class' => 'form-row form-choices'], + 'placeholder' => 'cybersecurity_report.all_versions', + 'label' => 'cybersecurity_report.version', + 'label_attr' => ['class' => 'label'], + 'data' => self::DEFAULT_CYBERSECURITY_MILESTONE, + ]) + ->add('fromDate', DateType::class, [ + 'widget' => 'single_text', + 'input' => 'datetime', + 'required' => false, + 'label' => 'cybersecurity_report.from_date', + 'label_attr' => ['class' => 'label'], + 'by_reference' => true, + 'data' => $options['fromDate'] ?? $this->cybersecurityReportService->getDefaultFromDate(), + 'attr' => [ + 'class' => 'form-element', + 'onchange' => 'this.form.submit()', + ], + ]) + ->add('toDate', DateType::class, [ + 'widget' => 'single_text', + 'input' => 'datetime', + 'required' => false, + 'label' => 'cybersecurity_report.to_date', + 'label_attr' => ['class' => 'label'], + 'data' => $options['toDate'] ?? $this->cybersecurityReportService->getDefaultToDate(), + 'by_reference' => true, + 'attr' => [ + 'class' => 'form-element', + 'onchange' => 'this.form.submit()', + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => CybersecurityReportFormData::class, + ]) + ->setRequired('data_provider') + ->setRequired('version') + ; + } +} diff --git a/src/Model/Reports/CybersecurityReportFormData.php b/src/Model/Reports/CybersecurityReportFormData.php new file mode 100644 index 00000000..89e6e1cf --- /dev/null +++ b/src/Model/Reports/CybersecurityReportFormData.php @@ -0,0 +1,14 @@ +modify('first day of this month'); + + return $fromDate; + } + + /** + * @throws \DateMalformedStringException + */ + public function getDefaultToDate(): \DateTime + { + $fromDate = new \DateTime(); + $fromDate->modify('last day of this month'); + + return $fromDate; + } +} diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index f9543127..e3e4aff3 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -585,6 +585,16 @@ hour_report: tags: 'Tags' sync_project_action: 'Synkronisér projekt' +cybersecurity_report: + title: 'Cybersikkerhedsrapport' + data_provider: 'Datakilde' + select_data_provider: 'Vælg datakilde' + data_provider_helptext: '' + version: 'Version' + from_date: 'Fra dato' + to_date: 'Til dato' + + view: list_title: 'Views' list_description: 'Oversigt over de views der er mulige i EConomics' From ddbc714eb431c23fa82f9b354fdfbf622702705c Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Tue, 20 Jan 2026 14:47:55 +0100 Subject: [PATCH 06/24] Added service method and data models --- assets/styles/app.css | 4 + .../CybersecurityReportController.php | 19 +++- src/Form/CybersecurityReportType.php | 14 ++- .../Reports/CybersecurityProjectData.php | 18 +++ src/Model/Reports/CybersecurityReportData.php | 13 +++ src/Model/Reports/CybersecurityTicketData.php | 20 ++++ .../Reports/CybersecurityWorklogData.php | 12 ++ src/Repository/IssueRepository.php | 10 ++ src/Service/CybersecurityReportService.php | 104 ++++++++++++++++++ .../reports/cybersecurity_report.html.twig | 84 ++++++++++++++ 10 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 src/Model/Reports/CybersecurityProjectData.php create mode 100644 src/Model/Reports/CybersecurityReportData.php create mode 100644 src/Model/Reports/CybersecurityTicketData.php create mode 100644 src/Model/Reports/CybersecurityWorklogData.php diff --git a/assets/styles/app.css b/assets/styles/app.css index 4fd0eafe..4870a381 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -148,6 +148,10 @@ @apply grid gap-3 mb-6 md:grid-cols-6; } + #cybersecurity_report { + @apply grid gap-3 mb-6 md:grid-cols-6; + } + .form-default { @apply grid gap-3 mb-6 md:grid-cols-6; } diff --git a/src/Controller/CybersecurityReportController.php b/src/Controller/CybersecurityReportController.php index 48f711c5..14c6147a 100644 --- a/src/Controller/CybersecurityReportController.php +++ b/src/Controller/CybersecurityReportController.php @@ -5,6 +5,7 @@ use App\Form\CybersecurityReportType; use App\Model\Reports\CybersecurityReportFormData; use App\Repository\DataProviderRepository; +use App\Service\CybersecurityReportService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -18,6 +19,7 @@ final class CybersecurityReportController extends AbstractController public function __construct( private readonly DataProviderRepository $dataProviderRepository, private readonly ?string $defaultDataProvider, + private readonly CybersecurityReportService $cybersecurityReportService, ) { } #[Route('/', name: 'app_cybersecurity_report')] @@ -40,14 +42,27 @@ public function index(Request $request): Response $form = $this->createForm(CybersecurityReportType::class, $reportFormData, [ // Since this is only a filtering form, csrf is not needed. 'csrf_protection' => false, - 'action' => $this->generateUrl('app_hour_report'), + 'action' => $this->generateUrl('app_cybersecurity_report'), 'method' => 'GET', 'attr' => [ - 'id' => 'hour_report', + 'id' => 'cybersecurity_report', ], 'data_provider' => $dataProvider, 'version' => $version, ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $version = $form->get('version')->getData() ?? null; + $fromDate = $form->get('fromDate')->getData() ?? null; + $toDate = $form->get('toDate')->getData() ?? null; + + if ($version && $fromDate && $toDate) { + $reportData = $this->cybersecurityReportService->getCybersecurityReport($fromDate, $toDate, $version); + } + } + return $this->render('reports/reports.html.twig', [ 'controller_name' => 'CybersecurityReportController', 'form' => $form, diff --git a/src/Form/CybersecurityReportType.php b/src/Form/CybersecurityReportType.php index 5da03eb9..243f734f 100644 --- a/src/Form/CybersecurityReportType.php +++ b/src/Form/CybersecurityReportType.php @@ -3,8 +3,10 @@ namespace App\Form; use App\Entity\DataProvider; +use App\Entity\Version; use App\Model\Reports\CybersecurityReportFormData; use App\Repository\DataProviderRepository; +use App\Repository\VersionRepository; use App\Service\CybersecurityReportService; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; @@ -20,11 +22,16 @@ public function __construct( private readonly CybersecurityReportService $cybersecurityReportService, private readonly DataProviderRepository $dataProviderRepository, private readonly ?string $defaultDataProvider, + private readonly VersionRepository $versionRepository, ) { } + /** + * @throws \DateMalformedStringException + */ public function buildForm(FormBuilderInterface $builder, array $options): void { + $version = $this->versionRepository->findOneBy(['name' => self::DEFAULT_CYBERSECURITY_MILESTONE]); $dataProviders = $this->dataProviderRepository->findAll(); $defaultProvider = $this->dataProviderRepository->find($this->defaultDataProvider); @@ -49,18 +56,17 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]) ->add('version', ChoiceType::class, [ 'choices' => [ - self::DEFAULT_CYBERSECURITY_MILESTONE => self::DEFAULT_CYBERSECURITY_MILESTONE, + self::DEFAULT_CYBERSECURITY_MILESTONE => $version, ], - 'required' => false, + 'required' => true, 'attr' => [ 'class' => 'form-element', 'onchange' => 'this.form.submit()', ], 'row_attr' => ['class' => 'form-row form-choices'], - 'placeholder' => 'cybersecurity_report.all_versions', 'label' => 'cybersecurity_report.version', 'label_attr' => ['class' => 'label'], - 'data' => self::DEFAULT_CYBERSECURITY_MILESTONE, + 'data' => $version, ]) ->add('fromDate', DateType::class, [ 'widget' => 'single_text', diff --git a/src/Model/Reports/CybersecurityProjectData.php b/src/Model/Reports/CybersecurityProjectData.php new file mode 100644 index 00000000..7bfb1e5c --- /dev/null +++ b/src/Model/Reports/CybersecurityProjectData.php @@ -0,0 +1,18 @@ + + */ + public array $projects = []; + + public float $totalSpent = 0.0; +} diff --git a/src/Model/Reports/CybersecurityTicketData.php b/src/Model/Reports/CybersecurityTicketData.php new file mode 100644 index 00000000..9e6fde97 --- /dev/null +++ b/src/Model/Reports/CybersecurityTicketData.php @@ -0,0 +1,20 @@ +getResult(); } + + public function issuesContainingVersionTitle(string $versionTitle): array + { + $qb = $this->createQueryBuilder('issue') + ->where(':version MEMBER OF issue.versions') + ->setParameter('version', $version); + + return $qb->getQuery()->getResult(); + } + } diff --git a/src/Service/CybersecurityReportService.php b/src/Service/CybersecurityReportService.php index ceb79f1b..90a0cda2 100644 --- a/src/Service/CybersecurityReportService.php +++ b/src/Service/CybersecurityReportService.php @@ -2,16 +2,118 @@ namespace App\Service; +use App\Entity\Version; +use App\Entity\Worklog; +use App\Model\Reports\HourReportWorklog; use App\Repository\IssueRepository; use App\Repository\WorklogRepository; +use App\Model\Reports\CybersecurityProjectData; +use App\Model\Reports\CybersecurityReportData; +use App\Model\Reports\CybersecurityTicketData; +use App\Model\Reports\CybersecurityWorklogData; class CybersecurityReportService { public function __construct( + private readonly IssueRepository $issueRepository, + private readonly WorklogRepository $worklogRepository, ) { + + + } + + + public function getCybersecurityReport( + ?\DateTimeInterface $fromDate, + ?\DateTimeInterface $toDate, + ?Version $version, + ): CybersecurityReportData { + $report = new CybersecurityReportData(); + + $issues = $this->issueRepository + ->issuesContainingVersion($version); + + foreach ($issues as $issue) { + $timesheetData = $this->worklogRepository->findBy([ + 'issue' => $issue->getId(), + ]); + + [$timesheets, $totalTicketSpent] = $this->processTimesheetsData( + $timesheetData, + $fromDate, + $toDate + ); + + // Skip tickets without logged hours + if ($totalTicketSpent === 0.0) { + continue; + } + + $projectName = $issue->getProject()->getName(); + + if (!isset($report->projects[$projectName])) { + $report->projects[$projectName] = new CybersecurityProjectData( + $projectName + ); + } + + $ticket = new CybersecurityTicketData( + $issue->getId(), + $issue->getProjectTrackerId(), + $issue->getName(), + $totalTicketSpent, + $issue->getLinkToIssue(), + array_map( + fn ($worklog) => new CybersecurityWorklogData( + $worklog->id, + $worklog->hours + ), + $timesheets + ) + ); + + $project = $report->projects[$projectName]; + $project->tickets[] = $ticket; + $project->totalSpent += $totalTicketSpent; + + $report->totalSpent += $totalTicketSpent; + } + + return $report; } + private function processTimesheetsData(array $worklogs, ?\DateTimeInterface $fromDate = null, ?\DateTimeInterface $toDate = null): array + { + $timesheets = []; + $totalTicketSpent = 0; + + /** @var Worklog $worklog */ + foreach ($worklogs as $worklog) { + $timesheetDate = $worklog->getStarted(); + + if (null !== $fromDate) { + if ($timesheetDate < $fromDate) { + continue; + } + } + + if (null !== $toDate) { + if ($timesheetDate > $toDate) { + continue; + } + } + + $hoursSpent = $worklog->getTimeSpentSeconds() / 3600; + $timesheet = new HourReportWorklog($worklog->getId(), $hoursSpent); + $timesheets[] = $timesheet; + $totalTicketSpent += $hoursSpent; + } + + return [$timesheets, $totalTicketSpent]; + } + + /** * @throws \DateMalformedStringException */ @@ -33,4 +135,6 @@ public function getDefaultToDate(): \DateTime return $fromDate; } + + } diff --git a/templates/reports/cybersecurity_report.html.twig b/templates/reports/cybersecurity_report.html.twig index e69de29b..52381c92 100644 --- a/templates/reports/cybersecurity_report.html.twig +++ b/templates/reports/cybersecurity_report.html.twig @@ -0,0 +1,84 @@ +
+ + + + + + + + + + + {% for project in data.projects %} + + + {# PROJECT ROW #} + + + + + + {# TICKET ROWS #} + {% for ticket in project.tickets %} + + + + + {% endfor %} + + + {% endfor %} + + {# GRAND TOTAL #} + + + + + + + +
+ {{ 'hour_report.project'|trans }} + + {{ 'hour_report.logged_hours'|trans }} +
+
+ + {{ project.projectName }} + + +
+
+ {{ project.totalSpent }} +
+
+ +   {{ ticket.headline }} + + #{{ ticket.trackerId }} + + +
+
+ {{ ticket.totalSpent }} +
+ {{ 'hour_report.total'|trans }} + + {{ data.totalSpent }} +
+
From 167f6e869e80eac2e38fd6b362a0760be8787fd4 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Wed, 21 Jan 2026 09:38:51 +0100 Subject: [PATCH 07/24] Coding standards --- .../CybersecurityReportController.php | 3 ++- src/Form/CybersecurityReportType.php | 2 +- src/Model/Reports/CybersecurityProjectData.php | 5 ++--- src/Model/Reports/CybersecurityTicketData.php | 9 ++++----- src/Model/Reports/CybersecurityWorklogData.php | 8 ++++---- src/Repository/IssueRepository.php | 1 - src/Service/CybersecurityReportService.php | 17 +++++------------ 7 files changed, 18 insertions(+), 27 deletions(-) diff --git a/src/Controller/CybersecurityReportController.php b/src/Controller/CybersecurityReportController.php index 14c6147a..fc68a637 100644 --- a/src/Controller/CybersecurityReportController.php +++ b/src/Controller/CybersecurityReportController.php @@ -22,11 +22,12 @@ public function __construct( private readonly CybersecurityReportService $cybersecurityReportService, ) { } + #[Route('/', name: 'app_cybersecurity_report')] public function index(Request $request): Response { $reportData = null; - $reportFormData = new CyberSecurityReportFormData(); + $reportFormData = new CybersecurityReportFormData(); $dataProvider = null; $version = null; diff --git a/src/Form/CybersecurityReportType.php b/src/Form/CybersecurityReportType.php index 243f734f..55cbe587 100644 --- a/src/Form/CybersecurityReportType.php +++ b/src/Form/CybersecurityReportType.php @@ -3,7 +3,6 @@ namespace App\Form; use App\Entity\DataProvider; -use App\Entity\Version; use App\Model\Reports\CybersecurityReportFormData; use App\Repository\DataProviderRepository; use App\Repository\VersionRepository; @@ -18,6 +17,7 @@ class CybersecurityReportType extends AbstractType { private const string DEFAULT_CYBERSECURITY_MILESTONE = 'Cybersikkerhedsaftale'; + public function __construct( private readonly CybersecurityReportService $cybersecurityReportService, private readonly DataProviderRepository $dataProviderRepository, diff --git a/src/Model/Reports/CybersecurityProjectData.php b/src/Model/Reports/CybersecurityProjectData.php index 7bfb1e5c..e2f4e67a 100644 --- a/src/Model/Reports/CybersecurityProjectData.php +++ b/src/Model/Reports/CybersecurityProjectData.php @@ -11,8 +11,7 @@ final class CybersecurityProjectData public function __construct( public string $projectName, - public float $totalSpent = 0.0 - ) - { + public float $totalSpent = 0.0, + ) { } } diff --git a/src/Model/Reports/CybersecurityTicketData.php b/src/Model/Reports/CybersecurityTicketData.php index 9e6fde97..9961d4fe 100644 --- a/src/Model/Reports/CybersecurityTicketData.php +++ b/src/Model/Reports/CybersecurityTicketData.php @@ -8,13 +8,12 @@ final class CybersecurityTicketData * @param CybersecurityWorklogData[] $worklogs */ public function __construct( - public int $issueId, + public int $issueId, public string $trackerId, public string $headline, - public float $totalSpent, + public float $totalSpent, public string $linkToIssue, - public array $worklogs = [] - ) - { + public array $worklogs = [], + ) { } } diff --git a/src/Model/Reports/CybersecurityWorklogData.php b/src/Model/Reports/CybersecurityWorklogData.php index a2bf340b..7e5ef049 100644 --- a/src/Model/Reports/CybersecurityWorklogData.php +++ b/src/Model/Reports/CybersecurityWorklogData.php @@ -1,12 +1,12 @@ getQuery()->getResult(); } - } diff --git a/src/Service/CybersecurityReportService.php b/src/Service/CybersecurityReportService.php index 90a0cda2..dbd1ae43 100644 --- a/src/Service/CybersecurityReportService.php +++ b/src/Service/CybersecurityReportService.php @@ -4,26 +4,22 @@ use App\Entity\Version; use App\Entity\Worklog; -use App\Model\Reports\HourReportWorklog; -use App\Repository\IssueRepository; -use App\Repository\WorklogRepository; use App\Model\Reports\CybersecurityProjectData; use App\Model\Reports\CybersecurityReportData; use App\Model\Reports\CybersecurityTicketData; use App\Model\Reports\CybersecurityWorklogData; +use App\Model\Reports\HourReportWorklog; +use App\Repository\IssueRepository; +use App\Repository\WorklogRepository; class CybersecurityReportService { public function __construct( private readonly IssueRepository $issueRepository, private readonly WorklogRepository $worklogRepository, - ) - { - - + ) { } - public function getCybersecurityReport( ?\DateTimeInterface $fromDate, ?\DateTimeInterface $toDate, @@ -46,7 +42,7 @@ public function getCybersecurityReport( ); // Skip tickets without logged hours - if ($totalTicketSpent === 0.0) { + if (0.0 === $totalTicketSpent) { continue; } @@ -113,7 +109,6 @@ private function processTimesheetsData(array $worklogs, ?\DateTimeInterface $fro return [$timesheets, $totalTicketSpent]; } - /** * @throws \DateMalformedStringException */ @@ -135,6 +130,4 @@ public function getDefaultToDate(): \DateTime return $fromDate; } - - } From c82bde66d890022f261a49a86fdcf0d22792a258 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Wed, 21 Jan 2026 09:41:49 +0100 Subject: [PATCH 08/24] Coding standards --- src/Service/CybersecurityReportService.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Service/CybersecurityReportService.php b/src/Service/CybersecurityReportService.php index dbd1ae43..29462d82 100644 --- a/src/Service/CybersecurityReportService.php +++ b/src/Service/CybersecurityReportService.php @@ -27,6 +27,10 @@ public function getCybersecurityReport( ): CybersecurityReportData { $report = new CybersecurityReportData(); + if (!$version) { + return $report; + } + $issues = $this->issueRepository ->issuesContainingVersion($version); From c6b2a6bc5a3c807a9d3d4ae57d421269a7d550f7 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Wed, 21 Jan 2026 09:42:51 +0100 Subject: [PATCH 09/24] Coding standards and updated changelog --- CHANGELOG.md | 3 +++ src/Service/CybersecurityReportService.php | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d38b3b6f..ccb63e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-264](https://github.com/itk-dev/economics/pull/264) + Added cybersecurity report. + ## [3.0.0] - 2026-01-20 * [PR-262](https://github.com/itk-dev/economics/pull/262) diff --git a/src/Service/CybersecurityReportService.php b/src/Service/CybersecurityReportService.php index 29462d82..ae568002 100644 --- a/src/Service/CybersecurityReportService.php +++ b/src/Service/CybersecurityReportService.php @@ -30,7 +30,7 @@ public function getCybersecurityReport( if (!$version) { return $report; } - + $issues = $this->issueRepository ->issuesContainingVersion($version); From 0116a291d6d8bd9e73c90432601edece13472a2d Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Wed, 21 Jan 2026 09:47:21 +0100 Subject: [PATCH 10/24] Removed auto generated tests --- .../CybersecurityReportControllerTest.php | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 tests/Controller/CybersecurityReportControllerTest.php diff --git a/tests/Controller/CybersecurityReportControllerTest.php b/tests/Controller/CybersecurityReportControllerTest.php deleted file mode 100644 index 52648044..00000000 --- a/tests/Controller/CybersecurityReportControllerTest.php +++ /dev/null @@ -1,16 +0,0 @@ -request('GET', '/cybersecurity/report'); - - self::assertResponseIsSuccessful(); - } -} From 56fd44896c5b83553417d9a4684a3019fdb9d15c Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Wed, 21 Jan 2026 09:31:20 +0100 Subject: [PATCH 11/24] Added versionId to dataProviderIssueData and added version to issue during upsert --- src/Model/DataProvider/DataProviderIssueData.php | 1 + src/Service/DataProviderService.php | 8 ++++++++ src/Service/LeantimeApiService.php | 1 + 3 files changed, 10 insertions(+) diff --git a/src/Model/DataProvider/DataProviderIssueData.php b/src/Model/DataProvider/DataProviderIssueData.php index c06e54f1..74445221 100644 --- a/src/Model/DataProvider/DataProviderIssueData.php +++ b/src/Model/DataProvider/DataProviderIssueData.php @@ -21,6 +21,7 @@ public function __construct( public ?\DateTimeInterface $fetchTime, public ?string $url, public ?\DateTimeInterface $sourceModifiedDate, + public ?string $versionId, ) { } } diff --git a/src/Service/DataProviderService.php b/src/Service/DataProviderService.php index 59718805..dedb51d9 100644 --- a/src/Service/DataProviderService.php +++ b/src/Service/DataProviderService.php @@ -173,6 +173,14 @@ public function upsertIssue(DataProviderIssueData $upsertIssueData): void $issue->addEpic($epic); } + + if (!empty($upsertIssueData->versionId)) { + $version = $this->getVersion($upsertIssueData->versionId, $dataProvider); + + if ($version) { + $issue->addVersion($version); + } + } $issue->setResolutionDate($upsertIssueData->resolutionDate); $issue->setStatus($upsertIssueData->status); $issue->setPlanHours($upsertIssueData->plannedHours); diff --git a/src/Service/LeantimeApiService.php b/src/Service/LeantimeApiService.php index 7715b8a0..d0c1ff3e 100644 --- a/src/Service/LeantimeApiService.php +++ b/src/Service/LeantimeApiService.php @@ -276,6 +276,7 @@ private function getIssueUpsertFromResult(object $result, int $dataProviderId, \ $fetchDate, $this->linkToTicket($projectTrackerId, $dataProviderUrl), $this->getLeanDateTime($result->modified), + $result->milestoneId ); } From 35fbb4175fbe8c77c89e067d61fb558f8f381338 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Wed, 21 Jan 2026 09:44:10 +0100 Subject: [PATCH 12/24] Fixed merge conflict in changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb63e0b..4088f0aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [PR-264](https://github.com/itk-dev/economics/pull/264) Added cybersecurity report. +* [PR-265](https://github.com/itk-dev/economics/pull/265) + Add version to issue during sync. ## [3.0.0] - 2026-01-20 From 8dad77c6f8d862a264be1000424bc95012b1c9e0 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Wed, 21 Jan 2026 14:43:10 +0100 Subject: [PATCH 13/24] Use version title instead of Version entity in cybersecurity report controller --- src/Controller/CybersecurityReportController.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Controller/CybersecurityReportController.php b/src/Controller/CybersecurityReportController.php index fc68a637..7790ff79 100644 --- a/src/Controller/CybersecurityReportController.php +++ b/src/Controller/CybersecurityReportController.php @@ -30,7 +30,7 @@ public function index(Request $request): Response $reportFormData = new CybersecurityReportFormData(); $dataProvider = null; - $version = null; + $versionTitle = null; $requestData = $request->query->all('cybersecurity_report'); @@ -49,18 +49,18 @@ public function index(Request $request): Response 'id' => 'cybersecurity_report', ], 'data_provider' => $dataProvider, - 'version' => $version, + 'versionTitle' => $versionTitle, ]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $version = $form->get('version')->getData() ?? null; + $versionTitle = $form->get('versionTitle')->getData() ?? null; $fromDate = $form->get('fromDate')->getData() ?? null; $toDate = $form->get('toDate')->getData() ?? null; - if ($version && $fromDate && $toDate) { - $reportData = $this->cybersecurityReportService->getCybersecurityReport($fromDate, $toDate, $version); + if ($versionTitle && $fromDate && $toDate) { + $reportData = $this->cybersecurityReportService->getCybersecurityReport($fromDate, $toDate, $versionTitle); } } From d0f9cbe6016ac3333e7d7a4d6c3c30bd70923302 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Wed, 21 Jan 2026 14:43:10 +0100 Subject: [PATCH 14/24] Refactor cybersecurity report form to use version title and submit button --- src/Form/CybersecurityReportType.php | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Form/CybersecurityReportType.php b/src/Form/CybersecurityReportType.php index 55cbe587..9be851b8 100644 --- a/src/Form/CybersecurityReportType.php +++ b/src/Form/CybersecurityReportType.php @@ -5,12 +5,12 @@ use App\Entity\DataProvider; use App\Model\Reports\CybersecurityReportFormData; use App\Repository\DataProviderRepository; -use App\Repository\VersionRepository; use App\Service\CybersecurityReportService; 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\DateType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -20,10 +20,10 @@ class CybersecurityReportType extends AbstractType public function __construct( private readonly CybersecurityReportService $cybersecurityReportService, - private readonly DataProviderRepository $dataProviderRepository, - private readonly ?string $defaultDataProvider, - private readonly VersionRepository $versionRepository, - ) { + private readonly DataProviderRepository $dataProviderRepository, + private readonly ?string $defaultDataProvider, + ) + { } /** @@ -31,7 +31,6 @@ public function __construct( */ public function buildForm(FormBuilderInterface $builder, array $options): void { - $version = $this->versionRepository->findOneBy(['name' => self::DEFAULT_CYBERSECURITY_MILESTONE]); $dataProviders = $this->dataProviderRepository->findAll(); $defaultProvider = $this->dataProviderRepository->find($this->defaultDataProvider); @@ -47,26 +46,24 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label_attr' => ['class' => 'label'], 'placeholder' => 'cybersecurity_report.select_data_provider', 'attr' => [ - 'onchange' => 'this.form.submit()', 'class' => 'form-element', ], 'help' => 'cybersecurity_report.data_provider_helptext', 'data' => $defaultProvider, 'choices' => $dataProviders, ]) - ->add('version', ChoiceType::class, [ + ->add('versionTitle', ChoiceType::class, [ 'choices' => [ - self::DEFAULT_CYBERSECURITY_MILESTONE => $version, + self::DEFAULT_CYBERSECURITY_MILESTONE => self::DEFAULT_CYBERSECURITY_MILESTONE, ], 'required' => true, 'attr' => [ 'class' => 'form-element', - 'onchange' => 'this.form.submit()', ], 'row_attr' => ['class' => 'form-row form-choices'], - 'label' => 'cybersecurity_report.version', + 'label' => 'cybersecurity_report.versionTitle', 'label_attr' => ['class' => 'label'], - 'data' => $version, + 'data' => self::DEFAULT_CYBERSECURITY_MILESTONE, ]) ->add('fromDate', DateType::class, [ 'widget' => 'single_text', @@ -78,7 +75,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'data' => $options['fromDate'] ?? $this->cybersecurityReportService->getDefaultFromDate(), 'attr' => [ 'class' => 'form-element', - 'onchange' => 'this.form.submit()', ], ]) ->add('toDate', DateType::class, [ @@ -91,7 +87,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'by_reference' => true, 'attr' => [ 'class' => 'form-element', - 'onchange' => 'this.form.submit()', + ], + ]) + ->add('submit', SubmitType::class, [ + 'label' => 'workload_report.submit', + 'attr' => [ + 'class' => 'hour-report-submit button', ], ]); } @@ -102,7 +103,6 @@ public function configureOptions(OptionsResolver $resolver): void 'data_class' => CybersecurityReportFormData::class, ]) ->setRequired('data_provider') - ->setRequired('version') - ; + ->setRequired('versionTitle'); } } From 81fb882a57b86f72c0def70d70b77c7d4bfda3ca Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Wed, 21 Jan 2026 14:43:10 +0100 Subject: [PATCH 15/24] Align cybersecurity report DTOs with new fields and naming --- src/Model/Reports/CybersecurityProjectData.php | 1 + src/Model/Reports/CybersecurityReportFormData.php | 2 +- src/Model/Reports/CybersecurityTicketData.php | 2 +- src/Model/Reports/CybersecurityWorklogData.php | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Model/Reports/CybersecurityProjectData.php b/src/Model/Reports/CybersecurityProjectData.php index e2f4e67a..90027fbf 100644 --- a/src/Model/Reports/CybersecurityProjectData.php +++ b/src/Model/Reports/CybersecurityProjectData.php @@ -12,6 +12,7 @@ final class CybersecurityProjectData public function __construct( public string $projectName, public float $totalSpent = 0.0, + public bool $hasCybersecurityAgreement = false, ) { } } diff --git a/src/Model/Reports/CybersecurityReportFormData.php b/src/Model/Reports/CybersecurityReportFormData.php index 89e6e1cf..3368d881 100644 --- a/src/Model/Reports/CybersecurityReportFormData.php +++ b/src/Model/Reports/CybersecurityReportFormData.php @@ -8,7 +8,7 @@ class CybersecurityReportFormData { public DataProvider $dataProvider; - public Version $version; + public string $versionTitle; public \DateTimeInterface $fromDate; public \DateTimeInterface $toDate; } diff --git a/src/Model/Reports/CybersecurityTicketData.php b/src/Model/Reports/CybersecurityTicketData.php index 9961d4fe..e63f3998 100644 --- a/src/Model/Reports/CybersecurityTicketData.php +++ b/src/Model/Reports/CybersecurityTicketData.php @@ -9,7 +9,7 @@ final class CybersecurityTicketData */ public function __construct( public int $issueId, - public string $trackerId, + public string $projectTrackerId, public string $headline, public float $totalSpent, public string $linkToIssue, diff --git a/src/Model/Reports/CybersecurityWorklogData.php b/src/Model/Reports/CybersecurityWorklogData.php index 7e5ef049..7350b7a2 100644 --- a/src/Model/Reports/CybersecurityWorklogData.php +++ b/src/Model/Reports/CybersecurityWorklogData.php @@ -7,6 +7,7 @@ final class CybersecurityWorklogData public function __construct( public int $id, public float $hours, + public ?string $description, ) { } } From a6540b8ee3c3c6f75121a197a155cdf2fdad491d Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Wed, 21 Jan 2026 14:43:10 +0100 Subject: [PATCH 16/24] Query issues by version title instead of Version entity --- src/Repository/IssueRepository.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Repository/IssueRepository.php b/src/Repository/IssueRepository.php index 05115445..c6508a07 100644 --- a/src/Repository/IssueRepository.php +++ b/src/Repository/IssueRepository.php @@ -133,10 +133,15 @@ public function findIssuesInDateRange(string $startDate, string $endDate, ?Worke public function issuesContainingVersionTitle(string $versionTitle): array { - $qb = $this->createQueryBuilder('issue') - ->where(':version MEMBER OF issue.versions') - ->setParameter('version', $version); - - return $qb->getQuery()->getResult(); + return $this->createQueryBuilder('issue') + ->select('DISTINCT issue') + ->innerJoin('issue.versions', 'version') + ->andWhere('version.name = :versionTitle') + ->setParameter('versionTitle', $versionTitle) + ->getQuery() + ->getResult(); } + + + } From c122ba717b4f4020eb62085754a2a02fbbac6e11 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Wed, 21 Jan 2026 14:43:11 +0100 Subject: [PATCH 17/24] Add query for projects with cybersecurity agreements --- src/Repository/ProjectRepository.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 203b6f61..8f274fbe 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -3,6 +3,7 @@ namespace App\Repository; use App\Entity\Project; +use App\Entity\ServiceAgreement; use App\Model\Invoices\ProjectFilterData; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; @@ -105,4 +106,18 @@ public function getProjectTrackerIdsByDataProviders(array $dataProviders) return $qb->getQuery()->getSingleColumnResult(); } + + public function getProjectIdsWithCybersecurityAgreement(): array + { + $result = $this->_em->createQueryBuilder() + ->select('DISTINCT p.id') + ->from(ServiceAgreement::class, 'sa') + ->innerJoin('sa.project', 'p') + ->where('sa.cybersecurityAgreement IS NOT NULL') + ->getQuery() + ->getScalarResult(); + + return array_column($result, 'id'); + } + } From fda4ad833b235615f06c3809f2463fb8e7e83b49 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Wed, 21 Jan 2026 14:43:11 +0100 Subject: [PATCH 18/24] Add method to fetch worklogs by issue and period --- src/Repository/WorklogRepository.php | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/Repository/WorklogRepository.php b/src/Repository/WorklogRepository.php index b0c0e09d..58dd53d9 100644 --- a/src/Repository/WorklogRepository.php +++ b/src/Repository/WorklogRepository.php @@ -294,4 +294,38 @@ public function getWorklogsAttachedToInvoiceInDateRange(\DateTimeInterface $peri 'paginator' => $paginator, ]; } + + /** + * Get worklogs for a given issue, optionally restricted by period. + * + * @param int $issueId + * @param ?\DateTimeInterface $fromDate + * @param ?\DateTimeInterface $toDate + * + * @return Worklog[] + */ + public function getWorklogsByIssueAndPeriod(int $issueId, ?\DateTimeInterface $fromDate, ?\DateTimeInterface $toDate): array + { + $qb = $this->createQueryBuilder('w') + ->andWhere('w.issue = :issue') // <-- use the entity relation + ->setParameter('issue', $issueId); // Doctrine can accept the ID for a ManyToOne + + if ($fromDate) { + $qb->andWhere('w.started >= :fromDate') + ->setParameter('fromDate', $fromDate->format('Y-m-d 00:00:00')); + } + + if ($toDate) { + $qb->andWhere('w.started <= :toDate') + ->setParameter('toDate', $toDate->format('Y-m-d 23:59:59')); + } + + $qb->orderBy('w.started', 'ASC'); + + return $qb->getQuery()->getResult(); + } + + + + } From 0dcdbd638023778618b1892a8af981ae5b487633 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Wed, 21 Jan 2026 14:43:11 +0100 Subject: [PATCH 19/24] Refactor cybersecurity report service to aggregate worklogs by period --- src/Service/CybersecurityReportService.php | 107 +++++++++------------ 1 file changed, 47 insertions(+), 60 deletions(-) diff --git a/src/Service/CybersecurityReportService.php b/src/Service/CybersecurityReportService.php index ae568002..9c701895 100644 --- a/src/Service/CybersecurityReportService.php +++ b/src/Service/CybersecurityReportService.php @@ -2,77 +2,93 @@ namespace App\Service; -use App\Entity\Version; use App\Entity\Worklog; use App\Model\Reports\CybersecurityProjectData; use App\Model\Reports\CybersecurityReportData; use App\Model\Reports\CybersecurityTicketData; use App\Model\Reports\CybersecurityWorklogData; -use App\Model\Reports\HourReportWorklog; use App\Repository\IssueRepository; +use App\Repository\ProjectRepository; use App\Repository\WorklogRepository; -class CybersecurityReportService +readonly class CybersecurityReportService { + private const SECONDS_TO_HOURS = 1 / 3600; public function __construct( - private readonly IssueRepository $issueRepository, - private readonly WorklogRepository $worklogRepository, + private IssueRepository $issueRepository, + private WorklogRepository $worklogRepository, + private ProjectRepository $projectRepository, ) { } public function getCybersecurityReport( ?\DateTimeInterface $fromDate, ?\DateTimeInterface $toDate, - ?Version $version, + string $versionTitle, ): CybersecurityReportData { $report = new CybersecurityReportData(); - if (!$version) { - return $report; - } + // Fetch all project IDs that have a cybersecurity agreement + $projectIdsWithAgreement = array_flip( + $this->projectRepository->getProjectIdsWithCybersecurityAgreement() + ); - $issues = $this->issueRepository - ->issuesContainingVersion($version); + // Fetch issues that have the version with the given title + $issues = $this->issueRepository->issuesContainingVersionTitle($versionTitle); foreach ($issues as $issue) { - $timesheetData = $this->worklogRepository->findBy([ - 'issue' => $issue->getId(), - ]); - - [$timesheets, $totalTicketSpent] = $this->processTimesheetsData( - $timesheetData, + // Fetch worklogs for this issue restricted to the period + $worklogs = $this->worklogRepository->getWorklogsByIssueAndPeriod( + $issue->getId(), $fromDate, $toDate ); - // Skip tickets without logged hours - if (0.0 === $totalTicketSpent) { + // Sum total time spent (seconds → hours) + $totalTicketSpent = array_reduce( + $worklogs, + fn (float $carry, Worklog $w) => + $carry + ($w->getTimeSpentSeconds() * self::SECONDS_TO_HOURS), + 0 + ); + + if (0 === $totalTicketSpent) { continue; } + $projectEntity = $issue->getProject(); + $projectId = $projectEntity->getId(); + $projectName = $projectEntity->getName(); - $projectName = $issue->getProject()->getName(); - + // Create project entry once if (!isset($report->projects[$projectName])) { - $report->projects[$projectName] = new CybersecurityProjectData( - $projectName - ); + $projectData = new CybersecurityProjectData($projectName); + $projectData->hasCybersecurityAgreement = + isset($projectIdsWithAgreement[$projectId]); + + $report->projects[$projectName] = $projectData; } + // Create worklog DTOs + $worklogData = array_map( + fn (Worklog $w) => new CybersecurityWorklogData( + $w->getId(), + $w->getTimeSpentSeconds() * self::SECONDS_TO_HOURS, + $w->getDescription() + ), + $worklogs + ); + + // Create ticket DTO $ticket = new CybersecurityTicketData( $issue->getId(), $issue->getProjectTrackerId(), $issue->getName(), $totalTicketSpent, $issue->getLinkToIssue(), - array_map( - fn ($worklog) => new CybersecurityWorklogData( - $worklog->id, - $worklog->hours - ), - $timesheets - ) + $worklogData ); + // Attach ticket to project $project = $report->projects[$projectName]; $project->tickets[] = $ticket; $project->totalSpent += $totalTicketSpent; @@ -83,35 +99,6 @@ public function getCybersecurityReport( return $report; } - private function processTimesheetsData(array $worklogs, ?\DateTimeInterface $fromDate = null, ?\DateTimeInterface $toDate = null): array - { - $timesheets = []; - $totalTicketSpent = 0; - - /** @var Worklog $worklog */ - foreach ($worklogs as $worklog) { - $timesheetDate = $worklog->getStarted(); - - if (null !== $fromDate) { - if ($timesheetDate < $fromDate) { - continue; - } - } - - if (null !== $toDate) { - if ($timesheetDate > $toDate) { - continue; - } - } - - $hoursSpent = $worklog->getTimeSpentSeconds() / 3600; - $timesheet = new HourReportWorklog($worklog->getId(), $hoursSpent); - $timesheets[] = $timesheet; - $totalTicketSpent += $hoursSpent; - } - - return [$timesheets, $totalTicketSpent]; - } /** * @throws \DateMalformedStringException From 010954702322260169697e2fe7878b876b912add Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Wed, 21 Jan 2026 14:43:11 +0100 Subject: [PATCH 20/24] Redesign cybersecurity report view with expandable tickets and worklogs --- .../reports/cybersecurity_report.html.twig | 77 +++++++++++-------- 1 file changed, 47 insertions(+), 30 deletions(-) diff --git a/templates/reports/cybersecurity_report.html.twig b/templates/reports/cybersecurity_report.html.twig index 52381c92..70da5726 100644 --- a/templates/reports/cybersecurity_report.html.twig +++ b/templates/reports/cybersecurity_report.html.twig @@ -1,18 +1,16 @@ -
- +
- + {{ stimulus_controller('planning-scroll') }} + > - @@ -20,31 +18,32 @@ {% for project in data.projects %} - - {# PROJECT ROW #} + data-toggle-id="{{ project.projectName }}" + > - - {# TICKET ROWS #} {% for ticket in project.tickets %} - - {% endfor %} + {% for worklog in ticket.worklogs %} + + + + + {% endfor %} + {% endfor %} {% endfor %} - {# GRAND TOTAL #} - - -
- {{ 'hour_report.project'|trans }} + {{ 'cybersecurity_report.project'|trans }} - {{ 'hour_report.logged_hours'|trans }} + + {{ 'cybersecurity_report.logged_hours'|trans }}
- - {{ project.projectName }} - -
+ {{ project.totalSpent }}
@@ -54,31 +53,49 @@ - #{{ ticket.trackerId }} + #{{ ticket.projectTrackerId }} + + - {{ ticket.totalSpent }} + + {{ ticket.totalSpent|number_format(2) }}
+
+ {{ worklog.description ?: '—' }} +
+
+ {{ worklog.hours }} +
+ {{ 'hour_report.total'|trans }} - {{ data.totalSpent }} + + {{ data.totalSpent|number_format(2) }}
From b46dd6831939e4161043c0bf06166b0b42370e67 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Wed, 21 Jan 2026 14:43:11 +0100 Subject: [PATCH 21/24] Add Danish translations for cybersecurity report labels --- translations/messages.da.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index e3e4aff3..2d829579 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -590,9 +590,15 @@ cybersecurity_report: data_provider: 'Datakilde' select_data_provider: 'Vælg datakilde' data_provider_helptext: '' - version: 'Version' + versionTitle: 'Version' from_date: 'Fra dato' to_date: 'Til dato' + project: 'Projekt' + logged_hours: 'Loggede timer' + has_cybersecurity_agreement: 'Har CSA' + cybersecurity_agreement: 'Cybersikkerhedsaftale' + has_cybersecurity_agreement_true: '✅' + has_cybersecurity_agreement_false: '❌' view: From 925fb5e126f13b2e1bcba8de62092c83fcf34a58 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 22 Jan 2026 10:12:49 +0100 Subject: [PATCH 22/24] Enriched and adjusted data and template --- src/Form/CybersecurityReportType.php | 7 +- .../Reports/CybersecurityReportFormData.php | 1 - .../Reports/CybersecurityWorklogData.php | 1 + src/Repository/IssueRepository.php | 3 - src/Repository/ProjectRepository.php | 1 - src/Repository/WorklogRepository.php | 4 - src/Service/CybersecurityReportService.php | 10 +-- .../reports/cybersecurity_report.html.twig | 84 +++++++++++++------ translations/messages.da.yaml | 4 +- 9 files changed, 67 insertions(+), 48 deletions(-) diff --git a/src/Form/CybersecurityReportType.php b/src/Form/CybersecurityReportType.php index 9be851b8..7a4139d8 100644 --- a/src/Form/CybersecurityReportType.php +++ b/src/Form/CybersecurityReportType.php @@ -20,10 +20,9 @@ class CybersecurityReportType extends AbstractType public function __construct( private readonly CybersecurityReportService $cybersecurityReportService, - private readonly DataProviderRepository $dataProviderRepository, - private readonly ?string $defaultDataProvider, - ) - { + private readonly DataProviderRepository $dataProviderRepository, + private readonly ?string $defaultDataProvider, + ) { } /** diff --git a/src/Model/Reports/CybersecurityReportFormData.php b/src/Model/Reports/CybersecurityReportFormData.php index 3368d881..79eb1469 100644 --- a/src/Model/Reports/CybersecurityReportFormData.php +++ b/src/Model/Reports/CybersecurityReportFormData.php @@ -3,7 +3,6 @@ namespace App\Model\Reports; use App\Entity\DataProvider; -use App\Entity\Version; class CybersecurityReportFormData { diff --git a/src/Model/Reports/CybersecurityWorklogData.php b/src/Model/Reports/CybersecurityWorklogData.php index 7350b7a2..8380e86a 100644 --- a/src/Model/Reports/CybersecurityWorklogData.php +++ b/src/Model/Reports/CybersecurityWorklogData.php @@ -8,6 +8,7 @@ public function __construct( public int $id, public float $hours, public ?string $description, + public ?string $worker, ) { } } diff --git a/src/Repository/IssueRepository.php b/src/Repository/IssueRepository.php index c6508a07..f6d29599 100644 --- a/src/Repository/IssueRepository.php +++ b/src/Repository/IssueRepository.php @@ -141,7 +141,4 @@ public function issuesContainingVersionTitle(string $versionTitle): array ->getQuery() ->getResult(); } - - - } diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 8f274fbe..e3237108 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -119,5 +119,4 @@ public function getProjectIdsWithCybersecurityAgreement(): array return array_column($result, 'id'); } - } diff --git a/src/Repository/WorklogRepository.php b/src/Repository/WorklogRepository.php index 58dd53d9..c6993899 100644 --- a/src/Repository/WorklogRepository.php +++ b/src/Repository/WorklogRepository.php @@ -324,8 +324,4 @@ public function getWorklogsByIssueAndPeriod(int $issueId, ?\DateTimeInterface $f return $qb->getQuery()->getResult(); } - - - - } diff --git a/src/Service/CybersecurityReportService.php b/src/Service/CybersecurityReportService.php index 9c701895..879d448a 100644 --- a/src/Service/CybersecurityReportService.php +++ b/src/Service/CybersecurityReportService.php @@ -14,8 +14,9 @@ readonly class CybersecurityReportService { private const SECONDS_TO_HOURS = 1 / 3600; + public function __construct( - private IssueRepository $issueRepository, + private IssueRepository $issueRepository, private WorklogRepository $worklogRepository, private ProjectRepository $projectRepository, ) { @@ -47,8 +48,7 @@ public function getCybersecurityReport( // Sum total time spent (seconds → hours) $totalTicketSpent = array_reduce( $worklogs, - fn (float $carry, Worklog $w) => - $carry + ($w->getTimeSpentSeconds() * self::SECONDS_TO_HOURS), + fn (float $carry, Worklog $w) => $carry + ($w->getTimeSpentSeconds() * self::SECONDS_TO_HOURS), 0 ); @@ -73,7 +73,8 @@ public function getCybersecurityReport( fn (Worklog $w) => new CybersecurityWorklogData( $w->getId(), $w->getTimeSpentSeconds() * self::SECONDS_TO_HOURS, - $w->getDescription() + $w->getDescription(), + $w->getWorker() ), $worklogs ); @@ -99,7 +100,6 @@ public function getCybersecurityReport( return $report; } - /** * @throws \DateMalformedStringException */ diff --git a/templates/reports/cybersecurity_report.html.twig b/templates/reports/cybersecurity_report.html.twig index 70da5726..ddeb7191 100644 --- a/templates/reports/cybersecurity_report.html.twig +++ b/templates/reports/cybersecurity_report.html.twig @@ -1,5 +1,7 @@ -
+ {{ 'cybersecurity_report.logged_hours'|trans }} + @@ -20,36 +25,45 @@ {{ stimulus_controller('toggle-parent-child') }} data-toggle-id="{{ project.projectName }}" > + + {# ─────────────── Project row ─────────────── #} - + + + {# ─────────────── Issue rows ─────────────── #} {% for ticket in project.tickets %} - + + + {# ─────────────── Worklog rows ─────────────── #} {% for worklog in ticket.worklogs %} - - + - + {% endfor %} {% endfor %} + {% endfor %} @@ -93,7 +123,7 @@ {{ 'hour_report.total'|trans }} diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 2d829579..36794f6c 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -595,10 +595,8 @@ cybersecurity_report: to_date: 'Til dato' project: 'Projekt' logged_hours: 'Loggede timer' - has_cybersecurity_agreement: 'Har CSA' - cybersecurity_agreement: 'Cybersikkerhedsaftale' + has_cybersecurity_agreement: 'Har Cybersikkerhedsaftale' has_cybersecurity_agreement_true: '✅' - has_cybersecurity_agreement_false: '❌' view: From 6050d46b66d36a96ca9449753804a5e72de504dd Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Thu, 22 Jan 2026 10:30:34 +0100 Subject: [PATCH 23/24] Coding standards --- src/Service/CybersecurityReportService.php | 2 +- templates/reports/cybersecurity_report.html.twig | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Service/CybersecurityReportService.php b/src/Service/CybersecurityReportService.php index 879d448a..172b15a2 100644 --- a/src/Service/CybersecurityReportService.php +++ b/src/Service/CybersecurityReportService.php @@ -71,7 +71,7 @@ public function getCybersecurityReport( // Create worklog DTOs $worklogData = array_map( fn (Worklog $w) => new CybersecurityWorklogData( - $w->getId(), + (int) $w->getId(), $w->getTimeSpentSeconds() * self::SECONDS_TO_HOURS, $w->getDescription(), $w->getWorker() diff --git a/templates/reports/cybersecurity_report.html.twig b/templates/reports/cybersecurity_report.html.twig index ddeb7191..152351b4 100644 --- a/templates/reports/cybersecurity_report.html.twig +++ b/templates/reports/cybersecurity_report.html.twig @@ -1,6 +1,5 @@
+ class="overflow-x-auto w-full">
+ {{ 'cybersecurity_report.has_cybersecurity_agreement'|trans }} +
-
- - {{ project.projectName }} - {% if project.hasCybersecurityAgreement %} - (CSA) - {% endif %} - +
+
+ + {{ project.projectName }} -
{{ project.totalSpent }} + {% if project.hasCybersecurityAgreement %} + {{ 'cybersecurity_report.has_cybersecurity_agreement_true'|trans }} + {% endif %} +
-
+
+
-   {{ ticket.headline }} + {{ ticket.headline }} @@ -57,33 +71,49 @@ - + class="expand-btn mr-4 w-4" + data-parent-id="{{ ticket.projectTrackerId }}" + {{ stimulus_action('toggle-parent-child', 'toggleChild') }} + > + + + + {% endif %}
- {{ ticket.totalSpent|number_format(2) }} + {{ ticket.totalSpent }} + +
+
- {{ worklog.description ?: '—' }} + {{ worklog.description ?: '—' }} 🧑
+ {{ worklog.hours }} + +
- {{ data.totalSpent|number_format(2) }} + {{ data.totalSpent }}
Date: Thu, 22 Jan 2026 10:36:41 +0100 Subject: [PATCH 24/24] Added comment --- src/Service/CybersecurityReportService.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Service/CybersecurityReportService.php b/src/Service/CybersecurityReportService.php index 172b15a2..7c4229ea 100644 --- a/src/Service/CybersecurityReportService.php +++ b/src/Service/CybersecurityReportService.php @@ -52,9 +52,11 @@ public function getCybersecurityReport( 0 ); + // Skip tickets with no time logged if (0 === $totalTicketSpent) { continue; } + $projectEntity = $issue->getProject(); $projectId = $projectEntity->getId(); $projectName = $projectEntity->getName();