feat: review received form submissions in the admin ui

This commit is contained in:
fromme committed 2026-09-24 16:35:18 +02:00
1 parent f073a2014d
commit cb2d64f2cd
19 files changed
+999 -7

No files matched your search

@@ -0,0 +1,63 @@
<?php
namespace App\Controller\Admin\ApplicationFormSubmission;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\ApplicationFormSubmission;
use App\Form\ApplicationFormSubmissionCallsType;
use App\Model\ApplicationFormSubmissionCallsDto;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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;
/**
* Saves the two "call held" checkboxes of the detail view. Rendered by DetailController, which
* builds the same form - this one only takes the submission.
*/
class CallsController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/application-form-submission/calls/{uuid}', name: 'app_admin_application_form_submission_calls', methods: ['POST'])]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(ApplicationFormSubmission $submission, Request $request): Response
{
$callsDto = new ApplicationFormSubmissionCallsDto($submission);
$form = $this->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'),
]);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Controller\Admin\ApplicationFormSubmission;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\ApplicationFormSubmission;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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;
/**
* A real DELETE, not setDeleted(): the retention rule for job applications asks for the row to
* be gone, and "details" and "payload" go with it. The audit log keeps the uuid and who deleted
* it, deliberately without the applicant's data.
*/
class DeleteController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/application-form-submission/delete/{uuid}', name: 'app_admin_application_form_submission_delete')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(ApplicationFormSubmission $submission, Request $request): Response
{
if (true === $request->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,
]);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Controller\Admin\ApplicationFormSubmission;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\ApplicationFormSubmission;
use App\Form\ApplicationFormSubmissionCallsType;
use App\Model\ApplicationFormSubmissionCallsDto;
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;
class DetailController extends AbstractController
{
use ReturnUrlTrait;
#[Route('/admin/application-form-submission/detail/{uuid}', name: 'app_admin_application_form_submission_detail')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(ApplicationFormSubmission $submission, Request $request): Response
{
// Rendered here, taken by CallsController - the form has no validation to fail, so it
// never has to come back to this page with errors.
$callsForm = $this->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'),
]);
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Controller\Admin\ApplicationFormSubmission;
use App\Repository\ApplicationFormSubmissionRepository;
use App\Service\Common\ApplicationFormSubmissionFilterHandler;
use Knp\Component\Pager\PaginatorInterface;
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;
/**
* The forms the public websites posted here, newest first. Read-only apart from the follow-up
* dates and the deletion the retention rule asks for - nothing in this application creates a
* submission.
*/
class IndexController extends AbstractController
{
public function __construct(
private readonly ApplicationFormSubmissionRepository $submissionRepository,
private readonly PaginatorInterface $paginator,
private readonly ApplicationFormSubmissionFilterHandler $filterHandler,
) {
}
#[Route('/admin/application-form-submission', name: 'app_admin_application_form_submission_index')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$filterDto = $this->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,
]);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Controller\Common;
use App\Form\ApplicationFormSubmissionFilterType;
use App\Htmx\HxRedirectResponse;
use App\Service\Common\ApplicationFormSubmissionFilterHandler;
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;
class ApplicationFormSubmissionFilterController extends AbstractController
{
public function __construct(private readonly ApplicationFormSubmissionFilterHandler $filterHandler)
{
}
#[Route('/common/application-form-submission/filter', name: 'app_common_application_form_submission_filter')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$formData = $this->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');
}
}
+80 -7
View File
@@ -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<string, string|bool|list<string>>
*/
@@ -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<string, string|bool|list<string>>
*/
@@ -0,0 +1,41 @@
<?php
namespace App\Form;
use App\Model\ApplicationFormSubmissionCallsDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ApplicationFormSubmissionCallsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->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,
])
;
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Form;
use App\Model\ApplicationFormSubmissionFilterDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ApplicationFormSubmissionFilterType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->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,
])
;
}
}
+12
View File
@@ -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',
@@ -0,0 +1,66 @@
<?php
namespace App\Model;
use App\Entity\ApplicationFormSubmission;
/**
* The two "call held" checkboxes of the detail view. The entity stores the day each call
* happened, the reviewer only ticks a box - this is the translation between the two.
*
* Unticking clears the date. Ticking a box whose date is already set leaves that date alone, so
* saving the form again does not silently move a call from the day it happened to today.
*/
class ApplicationFormSubmissionCallsDto
{
private bool $firstCallHeld;
private bool $secondCallHeld;
public function __construct(private readonly ApplicationFormSubmission $submission)
{
$this->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');
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Model;
class ApplicationFormSubmissionFilterDto extends AbstractFilterDto
{
protected ?string $name = null;
/**
* A range over the arrival time, not over the follow-up dates: "what came in that week" is
* the question a reviewer asks of this list. The datepicker hands over midnight, so the
* repository has to stretch dateTo to the end of its day - submittedAt carries a time.
*/
protected ?\DateTimeImmutable $dateFrom = null;
protected ?\DateTimeImmutable $dateTo = null;
public function getName(): ?string
{
return $this->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;
}
}
@@ -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))
;
}
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Service\Common;
use App\Model\AbstractFilterDto;
use App\Model\ApplicationFormSubmissionFilterDto;
class ApplicationFormSubmissionFilterHandler extends AbstractFilterHandler
{
protected string $namespace = 'filter:application_form_submission';
protected string $modelClass = ApplicationFormSubmissionFilterDto::class;
public function getFilterSettings(): ApplicationFormSubmissionFilterDto
{
$filterDto = new $this->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(),
]);
}
}