From cb2d64f2cd4463e87c19e69ae854e89f5f8b8ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 24 Sep 2026 15:39:47 +0200 Subject: [PATCH] feat: review received form submissions in the admin ui --- migrations/Version20260924090000.php | 33 +++++ .../CallsController.php | 63 +++++++++ .../DeleteController.php | 58 ++++++++ .../DetailController.php | 40 ++++++ .../IndexController.php | 49 +++++++ ...licationFormSubmissionFilterController.php | 50 +++++++ src/Entity/ApplicationFormSubmission.php | 87 +++++++++++- .../ApplicationFormSubmissionCallsType.php | 41 ++++++ .../ApplicationFormSubmissionFilterType.php | 47 +++++++ src/Menu/AdminMenuBuilder.php | 12 ++ .../ApplicationFormSubmissionCallsDto.php | 66 +++++++++ .../ApplicationFormSubmissionFilterDto.php | 53 +++++++ .../ApplicationFormSubmissionRepository.php | 44 ++++++ ...ApplicationFormSubmissionFilterHandler.php | 43 ++++++ .../detail.html.twig | 93 ++++++++++++ .../index.html.twig | 133 ++++++++++++++++++ .../modal_delete.html.twig | 14 ++ ...plication_form_submission_filter.html.twig | 18 +++ .../ApplicationFormSubmissionCallsDtoTest.php | 62 ++++++++ 19 files changed, 999 insertions(+), 7 deletions(-) create mode 100644 migrations/Version20260924090000.php create mode 100644 src/Controller/Admin/ApplicationFormSubmission/CallsController.php create mode 100644 src/Controller/Admin/ApplicationFormSubmission/DeleteController.php create mode 100644 src/Controller/Admin/ApplicationFormSubmission/DetailController.php create mode 100644 src/Controller/Admin/ApplicationFormSubmission/IndexController.php create mode 100644 src/Controller/Common/ApplicationFormSubmissionFilterController.php create mode 100644 src/Form/ApplicationFormSubmissionCallsType.php create mode 100644 src/Form/ApplicationFormSubmissionFilterType.php create mode 100644 src/Model/ApplicationFormSubmissionCallsDto.php create mode 100644 src/Model/ApplicationFormSubmissionFilterDto.php create mode 100644 src/Service/Common/ApplicationFormSubmissionFilterHandler.php create mode 100644 templates/admin/application_form_submission/detail.html.twig create mode 100644 templates/admin/application_form_submission/index.html.twig create mode 100644 templates/admin/application_form_submission/modal_delete.html.twig create mode 100644 templates/common/modal_application_form_submission_filter.html.twig create mode 100644 tests/Model/ApplicationFormSubmissionCallsDtoTest.php diff --git a/migrations/Version20260924090000.php b/migrations/Version20260924090000.php new file mode 100644 index 0000000..4a42e43 --- /dev/null +++ b/migrations/Version20260924090000.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE application_form_submission ADD first_mail_sent_at DATETIME DEFAULT NULL, ADD second_mail_sent_at DATETIME DEFAULT NULL, ADD first_call_held_at DATE DEFAULT NULL, ADD second_call_held_at DATE DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE application_form_submission DROP first_mail_sent_at, DROP second_mail_sent_at, DROP first_call_held_at, DROP second_call_held_at'); + } +} diff --git a/src/Controller/Admin/ApplicationFormSubmission/CallsController.php b/src/Controller/Admin/ApplicationFormSubmission/CallsController.php new file mode 100644 index 0000000..823273a --- /dev/null +++ b/src/Controller/Admin/ApplicationFormSubmission/CallsController.php @@ -0,0 +1,63 @@ +createForm(ApplicationFormSubmissionCallsType::class, $callsDto); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $callsDto->applyToSubmission(); + $this->entityManager->flush(); + + $this->logger->info('Update application form submission calls', [ + 'uuid' => $submission->getUuid(), + 'firstCallHeldAt' => $submission->getFirstCallHeldAt()?->format('Y-m-d'), + 'secondCallHeldAt' => $submission->getSecondCallHeldAt()?->format('Y-m-d'), + ]); + + $this->addFlash('success', 'Die Calls wurden gespeichert'); + } + + return $this->redirect($this->detailUrl($submission, $request)); + } + + private function detailUrl(ApplicationFormSubmission $submission, Request $request): string + { + return $this->generateUrl('app_admin_application_form_submission_detail', [ + 'uuid' => $submission->getUuid(), + 'r' => $request->query->get('r'), + ]); + } +} diff --git a/src/Controller/Admin/ApplicationFormSubmission/DeleteController.php b/src/Controller/Admin/ApplicationFormSubmission/DeleteController.php new file mode 100644 index 0000000..aa88109 --- /dev/null +++ b/src/Controller/Admin/ApplicationFormSubmission/DeleteController.php @@ -0,0 +1,58 @@ +isMethod(Request::METHOD_POST)) { + $uuid = $submission->getUuid(); + + $this->entityManager->remove($submission); + $this->entityManager->flush(); + + $this->logger->info('Delete application form submission', [ + 'uuid' => $uuid, + 'form' => $submission->getForm(), + 'submittedAt' => $submission->getSubmittedAt()->format(\DateTimeInterface::ATOM), + ]); + + $this->addFlash('success', 'Das eingegangene Formular wurde gelöscht'); + + return new HxRedirectResponse( + $this->getReturnUrl($request, 'app_admin_application_form_submission_index') + ); + } + + return $this->render('admin/application_form_submission/modal_delete.html.twig', [ + 'submission' => $submission, + ]); + } +} diff --git a/src/Controller/Admin/ApplicationFormSubmission/DetailController.php b/src/Controller/Admin/ApplicationFormSubmission/DetailController.php new file mode 100644 index 0000000..2c834d3 --- /dev/null +++ b/src/Controller/Admin/ApplicationFormSubmission/DetailController.php @@ -0,0 +1,40 @@ +createForm( + ApplicationFormSubmissionCallsType::class, + new ApplicationFormSubmissionCallsDto($submission), + ['action' => $this->generateUrl('app_admin_application_form_submission_calls', [ + 'uuid' => $submission->getUuid(), + 'r' => $request->query->get('r'), + ])] + ); + + return $this->render('admin/application_form_submission/detail.html.twig', [ + 'submission' => $submission, + 'callsForm' => $callsForm->createView(), + 'returnUrl' => $this->getReturnUrl($request, 'app_admin_application_form_submission_index'), + ]); + } +} diff --git a/src/Controller/Admin/ApplicationFormSubmission/IndexController.php b/src/Controller/Admin/ApplicationFormSubmission/IndexController.php new file mode 100644 index 0000000..66fd003 --- /dev/null +++ b/src/Controller/Admin/ApplicationFormSubmission/IndexController.php @@ -0,0 +1,49 @@ +filterHandler->getFilterSettings(); + + $pagination = $this->paginator->paginate( + $this->submissionRepository->getListQuery($filterDto), + $request->query->getInt('page', 1), + $request->query->getInt('limit', 50), + [ + 'defaultSortFieldName' => 'submission.submittedAt', + 'defaultSortDirection' => 'desc', + ] + ); + + return $this->render('admin/application_form_submission/index.html.twig', [ + 'pagination' => $pagination, + 'filterDto' => $filterDto, + ]); + } +} diff --git a/src/Controller/Common/ApplicationFormSubmissionFilterController.php b/src/Controller/Common/ApplicationFormSubmissionFilterController.php new file mode 100644 index 0000000..730acdf --- /dev/null +++ b/src/Controller/Common/ApplicationFormSubmissionFilterController.php @@ -0,0 +1,50 @@ +filterHandler->getFilterSettings(); + $form = $this->createForm(ApplicationFormSubmissionFilterType::class, $formData); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->filterHandler->handleRequest($form); + + return new HxRedirectResponse( + $this->generateUrl('app_admin_application_form_submission_index') + ); + } + + return $this->render('common/modal_application_form_submission_filter.html.twig', [ + 'filterForm' => $form->createView(), + 'filterDto' => $form->getData(), + ]); + } + + #[Route('/common/application-form-submission/filter/reset', name: 'app_common_application_form_submission_filter_reset')] + #[IsGranted('ROLE_TEAM_ADMIN')] + public function reset(): Response + { + $this->filterHandler->resetFilterSettings(); + + return $this->redirectToRoute('app_admin_application_form_submission_index'); + } +} diff --git a/src/Entity/ApplicationFormSubmission.php b/src/Entity/ApplicationFormSubmission.php index da723ac..1378702 100644 --- a/src/Entity/ApplicationFormSubmission.php +++ b/src/Entity/ApplicationFormSubmission.php @@ -14,10 +14,13 @@ use Symfony\Component\Uid\Uuid; * first, other "collect it and let a human read it" forms later. Unrelated to * App\Entity\Application, which is a teamer applying for an assignment. * - * Deliberately neither Blameable nor SoftDeletable: nothing in this application creates these - * rows, and the retention rule for job applications asks for real deletion, so this is the one - * entity here that gets an actual DELETE. A purge has to cover "details" and "payload" as - * well - a json column is exactly what a deletion job forgets. + * Deliberately neither Blameable nor SoftDeletable. Nothing here creates a row - TYPO3 does - + * and the only thing staff write are the four follow-up dates, where the interesting fact is + * that the mail went out or the call happened, not who ticked it off; who did is in the audit + * log the mutating controllers write. No SoftDeletable because the retention rule for job + * applications asks for real deletion, so this is the one entity here that gets an actual + * DELETE. A purge has to cover "details" and "payload" as well - a json column is exactly what + * a deletion job forgets. * * The typed columns are the mapped core agreed with the TYPO3 side and all nullable: a form is * configured without a field as often as with it. "details" is everything else the visitor @@ -86,14 +89,36 @@ class ApplicationFormSubmission implements TimestampableEntityInterface #[ORM\Column(length: 255, nullable: true)] private ?string $city = null; + /** + * The follow-up a reviewer works through after a submission arrived: two mails and two + * phone calls, each stamped when it happened and null until it has. Staff state, never part + * of the payload - fromDto() must not touch them. + * + * The mails carry a time because a send time is a machine fact and stays one once the + * notification mail of §6 is automated. A held call does not: the reviewer records it + * afterwards and knows the day, so storing an invented time would only look precise. + */ + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $firstMailSentAt = null; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $secondMailSentAt = null; + + #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $firstCallHeldAt = null; + + #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $secondCallHeldAt = null; + /** * Everything the visitor filled in beyond the mapped core, keyed by the German field label. * Display only: the labels are staff-written and change on a reword, so nothing may query * this, build a column from it or migrate old rows to a new spelling (§3.3). * - * Note the labels come back in MySQL's order, not the form's - a json column normalises an - * object by sorting its keys by length. Render it in whatever order suits the reviewer; the - * form's own order is still in $payload, which is read as one blob and never sorted by us. + * The key order is whatever the engine hands back and is not part of what this carries: on + * MariaDB (dev and production) JSON is an alias for LONGTEXT and the document survives + * verbatim, on MySQL 8 an object is normalised and its keys come back sorted by length. + * Render it in whatever order suits the reviewer rather than relying on either. * * @var array> */ @@ -309,6 +334,54 @@ class ApplicationFormSubmission implements TimestampableEntityInterface return $this; } + public function getFirstMailSentAt(): ?\DateTimeImmutable + { + return $this->firstMailSentAt; + } + + public function setFirstMailSentAt(?\DateTimeImmutable $firstMailSentAt): static + { + $this->firstMailSentAt = $firstMailSentAt; + + return $this; + } + + public function getSecondMailSentAt(): ?\DateTimeImmutable + { + return $this->secondMailSentAt; + } + + public function setSecondMailSentAt(?\DateTimeImmutable $secondMailSentAt): static + { + $this->secondMailSentAt = $secondMailSentAt; + + return $this; + } + + public function getFirstCallHeldAt(): ?\DateTimeImmutable + { + return $this->firstCallHeldAt; + } + + public function setFirstCallHeldAt(?\DateTimeImmutable $firstCallHeldAt): static + { + $this->firstCallHeldAt = $firstCallHeldAt; + + return $this; + } + + public function getSecondCallHeldAt(): ?\DateTimeImmutable + { + return $this->secondCallHeldAt; + } + + public function setSecondCallHeldAt(?\DateTimeImmutable $secondCallHeldAt): static + { + $this->secondCallHeldAt = $secondCallHeldAt; + + return $this; + } + /** * @return array> */ diff --git a/src/Form/ApplicationFormSubmissionCallsType.php b/src/Form/ApplicationFormSubmissionCallsType.php new file mode 100644 index 0000000..9f873f0 --- /dev/null +++ b/src/Form/ApplicationFormSubmissionCallsType.php @@ -0,0 +1,41 @@ +add('firstCallHeld', CheckboxType::class, [ + 'label' => '1. Call geführt', + 'required' => false, + ]) + ->add('secondCallHeld', CheckboxType::class, [ + 'label' => '2. Call geführt', + 'required' => false, + ]) + ->add('save', SubmitType::class, [ + 'label' => 'speichern', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setDefaults([ + 'data_class' => ApplicationFormSubmissionCallsDto::class, + // The DTO is built from the submission and has no no-arg constructor. + 'empty_data' => null, + ]) + ; + } +} diff --git a/src/Form/ApplicationFormSubmissionFilterType.php b/src/Form/ApplicationFormSubmissionFilterType.php new file mode 100644 index 0000000..37a5ca8 --- /dev/null +++ b/src/Form/ApplicationFormSubmissionFilterType.php @@ -0,0 +1,47 @@ +add('name', TextType::class, [ + 'label' => 'Name', + 'required' => false, + 'help' => 'Vor- oder Nachname, Teil genügt', + ]) + ->add('dateFrom', DatepickerType::class, [ + 'label' => 'eingegangen von', + 'required' => false, + ]) + ->add('dateTo', DatepickerType::class, [ + 'label' => 'eingegangen bis', + 'required' => false, + ]) + ->add('apply', SubmitType::class, [ + 'label' => 'filtern', + ]) + ->add('reset', SubmitType::class, [ + 'label' => 'reset', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setDefaults([ + 'data_class' => ApplicationFormSubmissionFilterDto::class, + ]) + ; + } +} diff --git a/src/Menu/AdminMenuBuilder.php b/src/Menu/AdminMenuBuilder.php index a6a84c0..f76ae44 100644 --- a/src/Menu/AdminMenuBuilder.php +++ b/src/Menu/AdminMenuBuilder.php @@ -50,6 +50,18 @@ class AdminMenuBuilder extends AbstractMenuBuilder 'icon' => 'mail', ], ]); + $menu->addChild('Eingegangene Bewerbungen', [ + 'route' => 'app_admin_application_form_submission_index', + 'linkAttributes' => [ + 'title' => 'Eingegangene Bewerbungen', + ], + 'extras' => [ + 'icon' => 'file', + 'routes' => [ + ['pattern' => '/^app_admin_application_form_submission_/'], + ], + ], + ]); $documentMenu = $menu->addChild('Dokumente', [ 'extras' => [ 'icon' => 'document', diff --git a/src/Model/ApplicationFormSubmissionCallsDto.php b/src/Model/ApplicationFormSubmissionCallsDto.php new file mode 100644 index 0000000..d48e178 --- /dev/null +++ b/src/Model/ApplicationFormSubmissionCallsDto.php @@ -0,0 +1,66 @@ +firstCallHeld = null !== $submission->getFirstCallHeldAt(); + $this->secondCallHeld = null !== $submission->getSecondCallHeldAt(); + } + + public function isFirstCallHeld(): bool + { + return $this->firstCallHeld; + } + + public function setFirstCallHeld(bool $firstCallHeld): static + { + $this->firstCallHeld = $firstCallHeld; + + return $this; + } + + public function isSecondCallHeld(): bool + { + return $this->secondCallHeld; + } + + public function setSecondCallHeld(bool $secondCallHeld): static + { + $this->secondCallHeld = $secondCallHeld; + + return $this; + } + + public function applyToSubmission(): void + { + $this->submission + ->setFirstCallHeldAt($this->resolve($this->firstCallHeld, $this->submission->getFirstCallHeldAt())) + ->setSecondCallHeldAt($this->resolve($this->secondCallHeld, $this->submission->getSecondCallHeldAt())) + ; + } + + private function resolve(bool $held, ?\DateTimeImmutable $current): ?\DateTimeImmutable + { + if (false === $held) { + return null; + } + + return $current ?? new \DateTimeImmutable('today'); + } +} diff --git a/src/Model/ApplicationFormSubmissionFilterDto.php b/src/Model/ApplicationFormSubmissionFilterDto.php new file mode 100644 index 0000000..cb981c3 --- /dev/null +++ b/src/Model/ApplicationFormSubmissionFilterDto.php @@ -0,0 +1,53 @@ +name; + } + + public function setName(?string $name): static + { + $this->name = $name; + + return $this; + } + + public function getDateFrom(): ?\DateTimeImmutable + { + return $this->dateFrom; + } + + public function setDateFrom(?\DateTimeImmutable $dateFrom): static + { + $this->dateFrom = $dateFrom; + + return $this; + } + + public function getDateTo(): ?\DateTimeImmutable + { + return $this->dateTo; + } + + public function setDateTo(?\DateTimeImmutable $dateTo): static + { + $this->dateTo = $dateTo; + + return $this; + } +} diff --git a/src/Repository/ApplicationFormSubmissionRepository.php b/src/Repository/ApplicationFormSubmissionRepository.php index b5d4dcd..aca83df 100644 --- a/src/Repository/ApplicationFormSubmissionRepository.php +++ b/src/Repository/ApplicationFormSubmissionRepository.php @@ -3,7 +3,11 @@ namespace App\Repository; use App\Entity\ApplicationFormSubmission; +use App\Model\ApplicationFormSubmissionFilterDto; +use App\Repository\Traits\QueryHelperTrait; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\Query; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -16,8 +20,48 @@ use Doctrine\Persistence\ManagerRegistry; */ class ApplicationFormSubmissionRepository extends ServiceEntityRepository { + use QueryHelperTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, ApplicationFormSubmission::class); } + + public function getListQuery(ApplicationFormSubmissionFilterDto $filterDto): Query + { + $qb = $this->createQueryBuilder('submission'); + + $this->applyFilterCriteria($qb, $filterDto); + + return $qb->getQuery(); + } + + private function applyFilterCriteria(QueryBuilder $qb, ApplicationFormSubmissionFilterDto $filterDto): void + { + if (null !== $name = $filterDto->getName()) { + $qb + ->andWhere($qb->expr()->orX( + $qb->expr()->like('submission.firstName', ':name'), + $qb->expr()->like('submission.lastName', ':name') + )) + ->setParameter('name', '%'.$this->escapeLikeWildcards($name).'%') + ; + } + + if (null !== $dateFrom = $filterDto->getDateFrom()) { + $qb + ->andWhere($qb->expr()->gte('submission.submittedAt', ':dateFrom')) + ->setParameter('dateFrom', $dateFrom) + ; + } + + if (null !== $dateTo = $filterDto->getDateTo()) { + // The datepicker hands over midnight and submittedAt carries a time, so a plain + // lte would drop everything that arrived during the last day of the range. + $qb + ->andWhere($qb->expr()->lte('submission.submittedAt', ':dateTo')) + ->setParameter('dateTo', $dateTo->setTime(23, 59, 59)) + ; + } + } } diff --git a/src/Service/Common/ApplicationFormSubmissionFilterHandler.php b/src/Service/Common/ApplicationFormSubmissionFilterHandler.php new file mode 100644 index 0000000..2d2927e --- /dev/null +++ b/src/Service/Common/ApplicationFormSubmissionFilterHandler.php @@ -0,0 +1,43 @@ +modelClass(); + + if (null === $data = $this->getSession()->get($this->namespace)) { + return $filterDto; + } + + if (isset($data['name'])) { + $filterDto->setName($data['name']); + } + if (isset($data['date_from'])) { + $filterDto->setDateFrom($data['date_from']); + } + if (isset($data['date_to'])) { + $filterDto->setDateTo($data['date_to']); + } + + return $filterDto; + } + + protected function saveFilterSettings(AbstractFilterDto $filterDto): void + { + /* @var ApplicationFormSubmissionFilterDto $filterDto */ + $this->getSession()->set($this->namespace, [ + 'name' => $filterDto->getName(), + 'date_from' => $filterDto->getDateFrom(), + 'date_to' => $filterDto->getDateTo(), + ]); + } +} diff --git a/templates/admin/application_form_submission/detail.html.twig b/templates/admin/application_form_submission/detail.html.twig new file mode 100644 index 0000000..a0d8dc8 --- /dev/null +++ b/templates/admin/application_form_submission/detail.html.twig @@ -0,0 +1,93 @@ +{% extends 'admin/layout.html.twig' %} + +{% block title %}Eingegangenes Formular{% endblock %} + +{% macro row(label, value) %} +
+
+ {{ label }} +
+
+ {% if value is same as(true) %} + ja + {% elseif value is same as(false) %} + nein + {% elseif value is empty %} + - + {% else %} + {{ value|nl2br }} + {% endif %} +
+
+{% endmacro %} + +{% block content %} +
+

+ {{ submission.formLabel }} + {% if submission.lastName or submission.firstName %} + - {{ [submission.firstName, submission.lastName]|filter(part => part is not empty)|join(' ') }} + {% endif %} +

+ + zurück + +
+ +
+
+ {{ _self.row('eingegangen am', submission.submittedAt|date('d.m.Y, H:i')) }} + {{ _self.row('Name', [submission.firstName, submission.lastName]|filter(part => part is not empty)|join(' ')) }} + {{ _self.row('Geburtsdatum', submission.birthDate) }} + {{ _self.row('E-Mail', submission.email) }} + {{ _self.row('Telefon', submission.phone) }} + {{ _self.row('Mobil', submission.mobile) }} + {{ _self.row('Adresse', [submission.street, [submission.zipCode, submission.city]|filter(part => part is not empty)|join(' ')]|filter(part => part is not empty)|join(', ')) }} +
+
+ {{ _self.row('Formularkennung', submission.form) }} + {{ _self.row('Quelle', submission.pageUrl) }} +
+
+ +

+ Bearbeitung +

+
+
+
+ {{ _self.row('1. Mail versendet', submission.firstMailSentAt ? submission.firstMailSentAt|date('d.m.Y, H:i') : null) }} + {{ _self.row('2. Mail versendet', submission.secondMailSentAt ? submission.secondMailSentAt|date('d.m.Y, H:i') : null) }} +
+
+
+ {{ form_start(callsForm) }} +
+ {{ form_row(callsForm.firstCallHeld) }} + {% if submission.firstCallHeldAt %} +
geführt am {{ submission.firstCallHeldAt|date('d.m.Y') }}
+ {% endif %} + {{ form_row(callsForm.secondCallHeld) }} + {% if submission.secondCallHeldAt %} +
geführt am {{ submission.secondCallHeldAt|date('d.m.Y') }}
+ {% endif %} +
+ {{ form_widget(callsForm.save, { 'attr': { 'class': 'btn btn--small' } }) }} + {{ form_rest(callsForm) }} + {{ form_end(callsForm) }} +
+
+ +

+ Angaben aus dem Formular +

+ {% if submission.details is empty %} +

Das Formular hat über die Stammdaten hinaus nichts erfasst.

+ {% else %} +
+ {% for label, answer in submission.details %} + {{ _self.row(label, answer is iterable ? answer|join(', ') : answer) }} + {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/templates/admin/application_form_submission/index.html.twig b/templates/admin/application_form_submission/index.html.twig new file mode 100644 index 0000000..fb7172f --- /dev/null +++ b/templates/admin/application_form_submission/index.html.twig @@ -0,0 +1,133 @@ +{% extends 'admin/layout.html.twig' %} + +{% block title %}Eingegangene Formulare{% endblock %} + +{% block content %} +
+

+ Eingegangene Formulare +

+
+ + {% if filterDto.active %} + + Reset + + {% endif %} +
+
+
+
+ + + + + + + + + + + + + + + + {% for submission in pagination %} + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
+ {{ knp_pagination_sortable(pagination, 'Name', 'submission.lastName') }} + + {{ knp_pagination_sortable(pagination, 'E-Mail', 'submission.email') }} + + {{ knp_pagination_sortable(pagination, 'Formular', 'submission.formLabel') }} + + {{ knp_pagination_sortable(pagination, 'eingegangen', 'submission.submittedAt') }} + + {{ knp_pagination_sortable(pagination, '1. Mail', 'submission.firstMailSentAt') }} + + {{ knp_pagination_sortable(pagination, '2. Mail', 'submission.secondMailSentAt') }} + + {{ knp_pagination_sortable(pagination, '1. Call', 'submission.firstCallHeldAt') }} + + {{ knp_pagination_sortable(pagination, '2. Call', 'submission.secondCallHeldAt') }} +
+ + {% if submission.lastName or submission.firstName %} + {{ [submission.lastName, submission.firstName]|filter(part => part is not empty)|join(', ') }} + {% else %} + ohne Namen + {% endif %} + + + {% if submission.email %} + {{ submission.email }} + {% else %} + - + {% endif %} + + {{ submission.formLabel }} + + {{ submission.submittedAt|date('d.m.Y, H:i') }} + + {{ submission.firstMailSentAt ? submission.firstMailSentAt|date('d.m.Y, H:i') : '-' }} + + {{ submission.secondMailSentAt ? submission.secondMailSentAt|date('d.m.Y, H:i') : '-' }} + + {{ submission.firstCallHeldAt ? submission.firstCallHeldAt|date('d.m.Y') : '-' }} + + {{ submission.secondCallHeldAt ? submission.secondCallHeldAt|date('d.m.Y') : '-' }} + + + + Details + + + +
+ Keine Daten... +
+ {{ knp_pagination_render(pagination) }} +
+
+{% endblock %} diff --git a/templates/admin/application_form_submission/modal_delete.html.twig b/templates/admin/application_form_submission/modal_delete.html.twig new file mode 100644 index 0000000..854ba6b --- /dev/null +++ b/templates/admin/application_form_submission/modal_delete.html.twig @@ -0,0 +1,14 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du das Formular {{ submission.formLabel }} + {% if submission.lastName or submission.firstName %} + von {{ [submission.firstName, submission.lastName]|filter(part => part is not empty)|join(' ') }} + {% endif %} + vom {{ submission.submittedAt|date('d.m.Y, H:i') }} wirklich löschen? +
+ Die Daten werden endgültig entfernt und können nicht wiederhergestellt werden. +
+
+{% endblock %} diff --git a/templates/common/modal_application_form_submission_filter.html.twig b/templates/common/modal_application_form_submission_filter.html.twig new file mode 100644 index 0000000..796a27d --- /dev/null +++ b/templates/common/modal_application_form_submission_filter.html.twig @@ -0,0 +1,18 @@ +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Eingegangene Formulare filtern{% endblock %} + +{% block content %} + {{ form_start(filterForm, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' } }) }} +
+ {{ form_row(filterForm.name) }} + {{ form_row(filterForm.dateFrom) }} + {{ form_row(filterForm.dateTo) }} +
+
+ {{ form_widget(filterForm.apply, { 'attr': { 'class': 'btn' } }) }} + {{ form_widget(filterForm.reset, { 'attr': { 'class': 'btn btn--secondary' } }) }} +
+ {{ form_rest(filterForm) }} + {{ form_end(filterForm) }} +{% endblock %} diff --git a/tests/Model/ApplicationFormSubmissionCallsDtoTest.php b/tests/Model/ApplicationFormSubmissionCallsDtoTest.php new file mode 100644 index 0000000..031cf3c --- /dev/null +++ b/tests/Model/ApplicationFormSubmissionCallsDtoTest.php @@ -0,0 +1,62 @@ +submission(); + + $dto = new ApplicationFormSubmissionCallsDto($submission); + $dto->setFirstCallHeld(true)->applyToSubmission(); + + $this->assertSame( + (new \DateTimeImmutable('today'))->format('Y-m-d'), + $submission->getFirstCallHeldAt()?->format('Y-m-d') + ); + $this->assertNull($submission->getSecondCallHeldAt()); + } + + /** + * The reason the DTO exists: saving the form again must not move a call from the day it + * happened to today. + */ + public function testAnAlreadyHeldCallKeepsItsDate(): void + { + $held = new \DateTimeImmutable('2026-09-01'); + $submission = $this->submission()->setFirstCallHeldAt($held); + + $dto = new ApplicationFormSubmissionCallsDto($submission); + $this->assertTrue($dto->isFirstCallHeld()); + + $dto->applyToSubmission(); + + $this->assertSame($held, $submission->getFirstCallHeldAt()); + } + + public function testUntickingClearsTheDate(): void + { + $submission = $this->submission()->setSecondCallHeldAt(new \DateTimeImmutable('2026-09-01')); + + $dto = new ApplicationFormSubmissionCallsDto($submission); + $dto->setSecondCallHeld(false)->applyToSubmission(); + + $this->assertNull($submission->getSecondCallHeldAt()); + } + + private function submission(): ApplicationFormSubmission + { + return new ApplicationFormSubmission( + 'applicationform', + 'Bewerbungsformular', + new \DateTimeImmutable('2026-09-22T12:30:00+02:00') + ); + } +}