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

+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The follow-up a reviewer works through on a received form submission: two mails and two calls.
*
* Hand-written rather than generated: doctrine:migrations:diff also sweeps in the long-standing
* dev drift in the datetime and json column declarations and tables from other branches. These
* are the only statements this change needs.
*/
final class Version20260924090000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add the follow-up mail and call dates to application_form_submission';
}
public function up(Schema $schema): void
{
$this->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');
}
}
@@ -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(),
]);
}
}
@@ -0,0 +1,93 @@
{% extends 'admin/layout.html.twig' %}
{% block title %}Eingegangenes Formular{% endblock %}
{% macro row(label, value) %}
<div class="py-6 first:pt-0 last:pb-0 sm:grid sm:grid-cols-4 sm:gap-4">
<dt class="text-sm font-bold leading-6 text-gray-900">
{{ label }}
</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-3 sm:mt-0">
{% if value is same as(true) %}
ja
{% elseif value is same as(false) %}
nein
{% elseif value is empty %}
-
{% else %}
{{ value|nl2br }}
{% endif %}
</dd>
</div>
{% endmacro %}
{% block content %}
<div class="flex items-start justify-between pb-8">
<h1 class="text-2xl font-bold">
{{ submission.formLabel }}
{% if submission.lastName or submission.firstName %}
- {{ [submission.firstName, submission.lastName]|filter(part => part is not empty)|join(' ') }}
{% endif %}
</h1>
<a href="{{ returnUrl }}" class="btn btn--small btn--secondary">
zurück
</a>
</div>
<div class="grid lg:grid-cols-2 gap-x-8 gap-y-4 pb-8">
<dl class="divide-y divide-gray-100">
{{ _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(', ')) }}
</dl>
<dl class="divide-y divide-gray-100">
{{ _self.row('Formularkennung', submission.form) }}
{{ _self.row('Quelle', submission.pageUrl) }}
</dl>
</div>
<h2 class="font-bold text-lg pb-4">
Bearbeitung
</h2>
<div class="grid lg:grid-cols-2 gap-x-8 gap-y-8 pb-8">
<div>
<dl class="divide-y divide-gray-100">
{{ _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) }}
</dl>
</div>
<div>
{{ form_start(callsForm) }}
<div class="flex flex-col space-y-2 pb-4">
{{ form_row(callsForm.firstCallHeld) }}
{% if submission.firstCallHeldAt %}
<div class="text-sm">geführt am {{ submission.firstCallHeldAt|date('d.m.Y') }}</div>
{% endif %}
{{ form_row(callsForm.secondCallHeld) }}
{% if submission.secondCallHeldAt %}
<div class="text-sm">geführt am {{ submission.secondCallHeldAt|date('d.m.Y') }}</div>
{% endif %}
</div>
{{ form_widget(callsForm.save, { 'attr': { 'class': 'btn btn--small' } }) }}
{{ form_rest(callsForm) }}
{{ form_end(callsForm) }}
</div>
</div>
<h2 class="font-bold text-lg pb-4">
Angaben aus dem Formular
</h2>
{% if submission.details is empty %}
<p>Das Formular hat über die Stammdaten hinaus nichts erfasst.</p>
{% else %}
<dl class="divide-y divide-gray-100">
{% for label, answer in submission.details %}
{{ _self.row(label, answer is iterable ? answer|join(', ') : answer) }}
{% endfor %}
</dl>
{% endif %}
{% endblock %}
@@ -0,0 +1,133 @@
{% extends 'admin/layout.html.twig' %}
{% block title %}Eingegangene Formulare{% endblock %}
{% block content %}
<div class="flex items-start justify-between pb-4">
<h1 class="text-2xl font-bold">
Eingegangene Formulare
</h1>
<div class="flex flex-col items-end space-y-2 md:flex-row md:items-center md:space-x-2 md:space-y-0">
<button type="button"
class="{{ html_classes('btn btn--small', { 'btn--secondary': filterDto.active }) }}"
title="Filter"
hx-get="{{ path('app_common_application_form_submission_filter', { 'r': return_url() }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('filter', 'w-4 h-4 shrink-0') }}
{% if filterDto.active %}
<span class="whitespace-nowrap">{{ filterDto.activeFiltersCount }} Filter aktiv</span>
{% else %}
<span>Filter</span>
{% endif %}
</button>
{% if filterDto.active %}
<a href="{{ path('app_common_application_form_submission_filter_reset') }}"
class="btn btn--small btn--secondary">
Reset
</a>
{% endif %}
</div>
</div>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner">
<table class="data-table">
<thead>
<tr>
<th>
{{ knp_pagination_sortable(pagination, 'Name', 'submission.lastName') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'E-Mail', 'submission.email') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Formular', 'submission.formLabel') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'eingegangen', 'submission.submittedAt') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, '1. Mail', 'submission.firstMailSentAt') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, '2. Mail', 'submission.secondMailSentAt') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, '1. Call', 'submission.firstCallHeldAt') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, '2. Call', 'submission.secondCallHeldAt') }}
</th>
<th></th>
</tr>
</thead>
<tbody>
{% for submission in pagination %}
<tr>
<td>
<a href="{{ path('app_admin_application_form_submission_detail', { 'uuid': submission.uuid, 'r': return_url() }) }}">
{% if submission.lastName or submission.firstName %}
{{ [submission.lastName, submission.firstName]|filter(part => part is not empty)|join(', ') }}
{% else %}
ohne Namen
{% endif %}
</a>
</td>
<td>
{% if submission.email %}
<a href="mailto:{{ submission.email }}">{{ submission.email }}</a>
{% else %}
-
{% endif %}
</td>
<td>
{{ submission.formLabel }}
</td>
<td class="whitespace-nowrap">
{{ submission.submittedAt|date('d.m.Y, H:i') }}
</td>
<td class="whitespace-nowrap">
{{ submission.firstMailSentAt ? submission.firstMailSentAt|date('d.m.Y, H:i') : '-' }}
</td>
<td class="whitespace-nowrap">
{{ submission.secondMailSentAt ? submission.secondMailSentAt|date('d.m.Y, H:i') : '-' }}
</td>
<td class="whitespace-nowrap">
{{ submission.firstCallHeldAt ? submission.firstCallHeldAt|date('d.m.Y') : '-' }}
</td>
<td class="whitespace-nowrap">
{{ submission.secondCallHeldAt ? submission.secondCallHeldAt|date('d.m.Y') : '-' }}
</td>
<td>
<twig:DropDown>
<a href="{{ path('app_admin_application_form_submission_detail', { 'uuid': submission.uuid, 'r': return_url() }) }}"
class="text-gray-700 hover:text-primary block px-4 py-2 text-sm"
role="menuitem"
tabindex="-1">
Details
</a>
<button type="button"
class="text-red-500 hover:text-primary block px-4 py-2 text-sm w-full text-left"
role="menuitem"
tabindex="-1"
hx-get="{{ path('app_admin_application_form_submission_delete', { 'uuid': submission.uuid, 'r': return_url() }) }}"
hx-target="body"
hx-swap="beforeend">
löschen
</button>
</twig:DropDown>
</td>
</tr>
{% else %}
<tr>
<td colspan="9">
Keine Daten...
</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ knp_pagination_render(pagination) }}
</div>
</div>
{% endblock %}
@@ -0,0 +1,14 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block content %}
<div>
Möchtest du das Formular <em>{{ submission.formLabel }}</em>
{% if submission.lastName or submission.firstName %}
von <em>{{ [submission.firstName, submission.lastName]|filter(part => part is not empty)|join(' ') }}</em>
{% endif %}
vom {{ submission.submittedAt|date('d.m.Y, H:i') }} wirklich löschen?
<div class="pt-2 text-sm">
Die Daten werden endgültig entfernt und können nicht wiederhergestellt werden.
</div>
</div>
{% endblock %}
@@ -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' } }) }}
<div class="flex flex-col space-y-2 pb-4">
{{ form_row(filterForm.name) }}
{{ form_row(filterForm.dateFrom) }}
{{ form_row(filterForm.dateTo) }}
</div>
<div class="flex items-center space-x-2">
{{ form_widget(filterForm.apply, { 'attr': { 'class': 'btn' } }) }}
{{ form_widget(filterForm.reset, { 'attr': { 'class': 'btn btn--secondary' } }) }}
</div>
{{ form_rest(filterForm) }}
{{ form_end(filterForm) }}
{% endblock %}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Tests\Model;
use App\Entity\ApplicationFormSubmission;
use App\Model\ApplicationFormSubmissionCallsDto;
use PHPUnit\Framework\TestCase;
class ApplicationFormSubmissionCallsDtoTest extends TestCase
{
public function testTickingABoxStampsToday(): void
{
$submission = $this->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')
);
}
}