feat: enable entering additional remarks in offer confirmation
This commit is contained in:
@@ -35,6 +35,14 @@ class SendAccessLinkController extends AbstractController
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
// Older bookings can still be missing their contact data. Sending would be
|
||||
// silently skipped further down, so say so instead of reporting success.
|
||||
if (null === $booking->getEmail() || '' === trim($booking->getEmail())) {
|
||||
$this->addFlash('error', 'Für diese Buchung ist keine E-Mail-Adresse hinterlegt. Bitte ergänze sie, um den Zugangslink zu senden.');
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]));
|
||||
}
|
||||
|
||||
$this->bookingService->sendCustomerConfirmationEmail($booking);
|
||||
|
||||
$this->addFlash('success', 'Der Zugangslink wurde dem Kunden per E-Mail zugestellt.');
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Controller\Groups;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Form\Model\OfferAcceptDto;
|
||||
use App\Form\OfferAcceptConfirmationType;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Model\AccommodationBookingContext;
|
||||
@@ -97,13 +98,16 @@ class OfferController extends AbstractController
|
||||
return $this->redirectToOfferPage($request, $uuid);
|
||||
}
|
||||
|
||||
$confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, null, [
|
||||
$dto = new OfferAcceptDto(remarks: $booking->getRemarks());
|
||||
$confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, $dto, [
|
||||
'terms_url' => $this->termsUrlProvider->forAccommodation($booking->getAccommodation()),
|
||||
]);
|
||||
$confirmationForm->handleRequest($request);
|
||||
|
||||
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
|
||||
$this->bookingService->acceptBooking($booking);
|
||||
// The textarea is prefilled, so the submitted value is the complete remark —
|
||||
// an emptied field arrives as null and must clear the stored one, not keep it.
|
||||
$this->bookingService->acceptBooking($booking, $dto->remarks ?? '');
|
||||
$this->addFlash('success', 'Deine Buchung ist bestätigt.');
|
||||
|
||||
return $this->redirectToOfferPage($request, $uuid);
|
||||
|
||||
@@ -109,9 +109,15 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
|
||||
#[Assert\NotBlank(groups: ['edit'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
/**
|
||||
* The `offer` group covers the one field a customer-facing booking cannot do without:
|
||||
* the access link and every notification are addressed to it. It is enforced on
|
||||
* creation as soon as the booking is not a draft, while `edit` additionally demands
|
||||
* the rest of the contact data.
|
||||
*/
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\NotBlank(groups: ['edit'])]
|
||||
#[Assert\Email(groups: ['edit'])]
|
||||
#[Assert\NotBlank(groups: ['edit', 'offer'])]
|
||||
#[Assert\Email(groups: ['edit', 'offer'])]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
|
||||
@@ -10,9 +10,11 @@ use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
@@ -57,6 +59,13 @@ class AccommodationBookingCreateType extends AbstractType
|
||||
],
|
||||
'choice_label' => fn (AccommodationBookingStatus $status) => $status->label(),
|
||||
])
|
||||
// Not marked required in HTML: a draft may be saved without it, and the
|
||||
// validation group below enforces it for everything else.
|
||||
->add('email', EmailType::class, [
|
||||
'label' => 'E-Mail',
|
||||
'required' => false,
|
||||
'help' => 'Für ein Angebot erforderlich — der Zugangslink und alle Benachrichtigungen gehen an diese Adresse.',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -64,6 +73,15 @@ class AccommodationBookingCreateType extends AbstractType
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => AccommodationBooking::class,
|
||||
// A draft is a scratch record, but anything the customer can be pointed at
|
||||
// needs a reachable address — see the `offer` group on the entity. The rest of
|
||||
// the contact data is collected on the edit page, which validates `edit`.
|
||||
'validation_groups' => static function (FormInterface $form): array {
|
||||
/** @var AccommodationBooking $booking */
|
||||
$booking = $form->getData();
|
||||
|
||||
return $booking->isDraft() ? ['Default'] : ['Default', 'offer'];
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Carries what the offer acceptance modal submits: the AGB confirmation and an
|
||||
* optional remark. `remarks` is prefilled from the booking, so whatever comes back
|
||||
* is the full value the customer wants stored — an emptied textarea means "clear it".
|
||||
*/
|
||||
class OfferAcceptDto
|
||||
{
|
||||
public function __construct(
|
||||
#[Assert\IsTrue(message: 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.')]
|
||||
public bool $termsAccepted = false,
|
||||
#[Assert\Length(max: 2000, maxMessage: 'Die Anmerkungen dürfen maximal {{ limit }} Zeichen lang sein.')]
|
||||
public ?string $remarks = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Model\OfferAcceptDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\IsTrue;
|
||||
|
||||
/**
|
||||
* @extends AbstractType<null>
|
||||
* @extends AbstractType<OfferAcceptDto>
|
||||
*/
|
||||
class OfferAcceptConfirmationType extends AbstractType
|
||||
{
|
||||
@@ -22,19 +23,28 @@ class OfferAcceptConfirmationType extends AbstractType
|
||||
htmlspecialchars($options['terms_url'], ENT_QUOTES, 'UTF-8')
|
||||
);
|
||||
|
||||
$builder->add('termsAccepted', CheckboxType::class, [
|
||||
'label' => sprintf('Ich habe die %s gelesen und bin damit einverstanden, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink),
|
||||
'label_html' => true,
|
||||
'mapped' => false,
|
||||
'required' => true,
|
||||
'constraints' => [
|
||||
new IsTrue(message: 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.'),
|
||||
],
|
||||
]);
|
||||
$builder
|
||||
->add('remarks', TextareaType::class, [
|
||||
'label' => 'Anmerkungen (optional)',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'rows' => 4,
|
||||
'maxlength' => 2000,
|
||||
],
|
||||
])
|
||||
->add('termsAccepted', CheckboxType::class, [
|
||||
'label' => sprintf('Ich habe die %s gelesen und bin damit einverstanden, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink),
|
||||
'label_html' => true,
|
||||
'required' => true,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => OfferAcceptDto::class,
|
||||
]);
|
||||
$resolver->setRequired('terms_url');
|
||||
$resolver->setAllowedTypes('terms_url', 'string');
|
||||
}
|
||||
|
||||
@@ -387,14 +387,28 @@ class AccommodationBookingService
|
||||
/**
|
||||
* All accommodation mail is sent from the group desk address so customer replies
|
||||
* land there rather than in the general inbox.
|
||||
*
|
||||
* A booking can legitimately have no email yet (drafts, and offers created without
|
||||
* contact data), so a missing recipient is logged and skipped rather than thrown:
|
||||
* the caller's own work — accepting an offer, for instance — is already done and
|
||||
* must not fail over an undeliverable notification.
|
||||
*/
|
||||
private function sendBookingEmail(
|
||||
AccommodationBooking $booking,
|
||||
string $to,
|
||||
?string $to,
|
||||
string $subject,
|
||||
string $template,
|
||||
string $errorMessage,
|
||||
): void {
|
||||
if (null === $to || '' === trim($to)) {
|
||||
$this->logger->warning($errorMessage, [
|
||||
'booking_id' => $booking->getId(),
|
||||
'error' => 'No recipient email address on the booking.',
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->mailer->createAndSendEmail(
|
||||
[
|
||||
@@ -486,13 +500,21 @@ class AccommodationBookingService
|
||||
* only the status moves to Bestätigt, so an offer-originated booking remains
|
||||
* distinguishable from a direct one.
|
||||
* Idempotent — a no-op (including no emails) if the offer is not open any more.
|
||||
*
|
||||
* @param ?string $remarks null leaves the stored remark untouched, a string replaces it,
|
||||
* an empty string clears it
|
||||
*/
|
||||
public function acceptBooking(AccommodationBooking $booking): void
|
||||
public function acceptBooking(AccommodationBooking $booking, ?string $remarks = null): void
|
||||
{
|
||||
if (!$booking->isInquiry() || !$booking->isOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $remarks) {
|
||||
$trimmed = trim($remarks);
|
||||
$booking->setRemarks('' === $trimmed ? null : $trimmed);
|
||||
}
|
||||
|
||||
$booking->setStatus(AccommodationBookingStatus::Accepted);
|
||||
$booking->setAcceptedAt(new \DateTimeImmutable());
|
||||
$this->entityManager->flush();
|
||||
|
||||
Reference in New Issue
Block a user