feat: call off single disposition as admin with optional notification

This commit is contained in:
Björn Fromme
2025-07-22 16:15:18 +02:00
parent b5314d270a
commit 231d0f315b
19 changed files with 1383 additions and 14 deletions
+5
View File
@@ -29,3 +29,8 @@ yarn-error.log
###> liip/imagine-bundle ###
/public/media/cache/
###< liip/imagine-bundle ###
###> friendsofphp/php-cs-fixer ###
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###
+3 -1
View File
@@ -113,7 +113,8 @@
],
"post-update-cmd": [
"@auto-scripts"
]
],
"php-cs-fixer": "php-cs-fixer --rules=@Symfony"
},
"conflict": {
"symfony/symfony": "*"
@@ -126,6 +127,7 @@
},
"require-dev": {
"deployer/deployer": "^7.3",
"friendsofphp/php-cs-fixer": "^3.84",
"marcocesarato/php-conventional-changelog": "^1.17",
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "6.4.*",
Generated
+1030 -1
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -15,6 +15,7 @@ framework:
- ended
- checking_invoice
- completed
- called_off
transitions:
upload_contract:
from: new
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250722125647 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE disposition ADD called_off_by VARCHAR(64) DEFAULT NULL, ADD called_off_reason LONGTEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE disposition DROP called_off_by, DROP called_off_reason');
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Controller\Administrative\Disposition;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Event\DispositionCalledOffEvent;
use App\Form\DispositionCallOffType;
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;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class CallOffController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly LoggerInterface $logger,
) {
}
#[Route('/administrative/disposition/call-off/{uuid}', name: 'app_administrative_disposition_call_off')]
#[IsGranted('CALL_OFF', subject: 'disposition')]
public function index(Disposition $disposition, Request $request): Response
{
$form = $this->createForm(DispositionCallOffType::class, $disposition, ['hx_post' => $request->getUri()]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$disposition->setStatus(Assignment::STATUS_CALLED_OFF);
$sendNotification = $form->get('sendNotification')->getData();
$this->entityManager->flush();
$this
->eventDispatcher
->dispatch(new DispositionCalledOffEvent($disposition, $sendNotification), DispositionCalledOffEvent::NAME)
;
$assignment = $disposition->getAssignment();
$this->logger->info('Call off disposition', [
'assignment_id' => $disposition->getId(),
'destination' => (string) $assignment->getDestination(),
'job_profile' => $assignment->getJobProfile()->getName(),
]);
$this->addFlash('success', 'Der Einsatz wurde abgesagt');
return new HxRedirectResponse($this->generateUrl('app_administrative_assignment_detail', [
'uuid' => $assignment->getUuid(),
]));
}
return $this->render('administrative/disposition/modal_call_off.html.twig', [
'disposition' => $disposition,
'form' => $form->createView(),
]);
}
}
+7
View File
@@ -487,6 +487,13 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
});
}
public function getActiveDispositions(): Collection
{
return $this->dispositions->filter(function (Disposition $disposition) {
return Disposition::STATUS_CALLED_OFF !== $disposition->getStatus();
});
}
public function getConfirmedDispositions(): Collection
{
return $this->dispositions->filter(function (Disposition $disposition) {
+34
View File
@@ -25,6 +25,10 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
public const STATUS_CHECKING_INVOICE = 'checking_invoice';
public const STATUS_PAID = 'paid';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CALLED_OFF = 'called_off';
public const CALLED_OFF_BY_TEAMER = 'teamer';
public const CALLED_OFF_BY_OFFICE = 'office';
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -59,6 +63,12 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
#[ORM\JoinColumn(onDelete: 'SET NULL')]
private ?Feedback $feedback = null;
#[ORM\Column(length: 64, nullable: true)]
private ?string $calledOffBy = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $calledOffReason = null;
public function __construct(Application $application)
{
$this->uuid = Uuid::v4();
@@ -243,4 +253,28 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
return $period->isStarted();
}
public function getCalledOffBy(): ?string
{
return $this->calledOffBy;
}
public function setCalledOffBy(?string $calledOffBy): static
{
$this->calledOffBy = $calledOffBy;
return $this;
}
public function getCalledOffReason(): ?string
{
return $this->calledOffReason;
}
public function setCalledOffReason(?string $calledOffReason): static
{
$this->calledOffReason = $calledOffReason;
return $this;
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Event;
use App\Entity\Disposition;
use Symfony\Contracts\EventDispatcher\Event;
class DispositionCalledOffEvent extends Event
{
public const NAME = 'disposition.called_off';
public function __construct(private readonly Disposition $disposition, private readonly bool $sendNotification = false)
{
}
public function getDisposition(): Disposition
{
return $this->disposition;
}
public function isSendNotification(): bool
{
return $this->sendNotification;
}
}
@@ -7,6 +7,7 @@ use App\Entity\Application;
use App\Entity\Upload;
use App\Event\ApplicationStatusEvent;
use App\Event\AssignmentCalledOffEvent;
use App\Event\DispositionCalledOffEvent;
use App\Event\DispositionCreatedEvent;
use App\Event\DocumentConfirmedEvent;
use App\Event\DocumentRejectedEvent;
@@ -21,7 +22,7 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
public function __construct(
private readonly Mailer $mailer,
private readonly UserRepository $userRepository,
private readonly ContractRenderer $contractRenderer
private readonly ContractRenderer $contractRenderer,
) {
}
@@ -29,6 +30,7 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
{
return [
DispositionCreatedEvent::NAME => 'onDispositionCreated',
DispositionCalledOffEvent::NAME => 'onDispositionCalledOff',
DocumentUploadedEvent::NAME => 'onDocumentUploaded',
DocumentRejectedEvent::NAME => 'onDocumentRejected',
ApplicationStatusEvent::NAME => 'onApplicationStatus',
@@ -38,7 +40,7 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
}
/**
* Notify teamer about accepted application
* Notify teamer about accepted application.
*/
public function onDispositionCreated(DispositionCreatedEvent $event): void
{
@@ -97,7 +99,7 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
}
/**
* Notify managers about uploaded documents
* Notify managers about uploaded documents.
*/
public function onDocumentUploaded(DocumentUploadedEvent $event): void
{
@@ -142,7 +144,7 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
}
/**
* Notify teamer about rejected document
* Notify teamer about rejected document.
*/
public function onDocumentRejected(DocumentRejectedEvent $event): void
{
@@ -168,7 +170,7 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
}
/**
* Notify teamer about rejected application
* Notify teamer about rejected application.
*/
public function onApplicationStatus(ApplicationStatusEvent $event): void
{
@@ -189,6 +191,31 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
]);
}
/**
* Notify teamer about called-off disposition (if applicable).
*/
public function onDispositionCalledOff(DispositionCalledOffEvent $event): void
{
if (false === $event->isSendNotification()) {
return;
}
$disposition = $event->getDisposition();
$teamer = $disposition->getTeamer();
$this->mailer->createAndSendEmail([
'assignment' => $disposition->getAssignment(),
'reason' => $disposition->getCalledOffReason(),
], [
'to' => $teamer->getCommunication()->getEmail(),
'subject' => 'Dein Einsatz wurde abgesagt',
'template' => 'email/disposition_called_off.html.twig',
]);
}
/**
* Notify all assigned teamers about called-off assignment.
*/
public function onAssignmentCalledOff(AssignmentCalledOffEvent $event): void
{
$assignment = $event->getAssignment();
@@ -7,6 +7,7 @@ use App\Entity\Upload;
use App\Event\ApplicationCreatedEvent;
use App\Event\ApplicationDeletedEvent;
use App\Event\ApplicationStatusEvent;
use App\Event\DispositionCalledOffEvent;
use App\Event\DispositionCreatedEvent;
use App\Event\DispositionDeletedEvent;
use App\Event\DocumentConfirmedEvent;
@@ -27,6 +28,7 @@ class StaffingStatusSubscriber implements EventSubscriberInterface
ApplicationStatusEvent::NAME => 'onApplicationStatusUpdated',
DispositionCreatedEvent::NAME => 'onDispositionCreated',
DispositionDeletedEvent::NAME => 'onDispositionDeleted',
DispositionCalledOffEvent::NAME => 'onDispositionCalledOff',
DocumentConfirmedEvent::NAME => 'onDocumentConfirmed',
];
}
@@ -67,6 +69,13 @@ class StaffingStatusSubscriber implements EventSubscriberInterface
$this->updateStaffingStatus($assignment);
}
public function onDispositionCalledOff(DispositionCalledOffEvent $event): void
{
$assignment = $event->getDisposition()->getAssignment();
$this->updateStaffingStatus($assignment);
}
public function onDocumentConfirmed(DocumentConfirmedEvent $event): void
{
$document = $event->getDocument();
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Form;
use App\Entity\Disposition;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
class DispositionCallOffType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('calledOffBy', ChoiceType::class, [
'label' => 'abgesagt durch',
'choices' => [
'Teamer:in' => Disposition::CALLED_OFF_BY_TEAMER,
'E&P' => Disposition::CALLED_OFF_BY_OFFICE,
],
])
->add('calledOffReason', TextareaType::class, [
'label' => 'Begründung',
'attr' => [
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
],
'constraints' => [
new NotBlank([
'message' => 'Bitte gib die Begründung ein',
]),
],
])
->add('sendNotification', CheckboxType::class, [
'label' => 'Benachrichtigung mit Begründung versenden',
'required' => false,
'mapped' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Disposition::class,
]);
}
}
+12
View File
@@ -18,6 +18,7 @@ class DispositionVoter extends Voter
public const CONTRACT_SUPPLEMENTARY = 'CONTRACT_SUPPLEMENTARY';
public const INVOICE = 'INVOICE';
public const FEEDBACK = 'FEEDBACK';
public const CALL_OFF = 'CALL_OFF';
public function __construct(private readonly Security $security)
{
@@ -37,6 +38,7 @@ class DispositionVoter extends Voter
static::CONTRACT_SUPPLEMENTARY,
static::INVOICE,
static::FEEDBACK,
static::CALL_OFF,
]);
}
@@ -53,6 +55,8 @@ class DispositionVoter extends Voter
static::FEEDBACK => $this->security->isGranted('ROLE_ADMINISTRATIVE')
|| $this->assertHouseManagerAccess($token, $disposition),
static::CONTRACT_SUPPLEMENTARY => $this->assertContractUploadAllowed($disposition),
static::CALL_OFF => $this->security->isGranted('ROLE_ADMINISTRATIVE')
&& Disposition::STATUS_CALLED_OFF !== $disposition->getStatus(),
default => false,
};
}
@@ -63,6 +67,10 @@ class DispositionVoter extends Voter
return false;
}
if (Disposition::STATUS_CALLED_OFF === $disposition->getStatus()) {
return false;
}
/** @var User $user */
$user = $token->getUser();
@@ -95,6 +103,10 @@ class DispositionVoter extends Voter
return false;
}
if (Disposition::STATUS_CALLED_OFF === $disposition->getStatus()) {
return false;
}
return null === $disposition->getDocumentByType(Upload::TYPE_CONTRACT);
}
}
+12
View File
@@ -47,6 +47,18 @@
"config/packages/flagception.yaml"
]
},
"friendsofphp/php-cs-fixer": {
"version": "3.84",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "be2103eb4a20942e28a6dd87736669b757132435"
},
"files": [
".php-cs-fixer.dist.php"
]
},
"knplabs/knp-menu-bundle": {
"version": "v3.2.0"
},
@@ -170,7 +170,11 @@
{% endif %}
</td>
<td>
{% if disposition.calledOffReason %}
{{ disposition.calledOffReason|default('-')|nl2br }}
{% else %}
{{ disposition.remarks|default('-')|nl2br }}
{% endif %}
</td>
<td>
<div class="flex items-start space-x-2">
@@ -197,6 +201,16 @@
{{ icon('upload') }}
</button>
{% endif %}
{% if is_granted('CALL_OFF', disposition) %}
<button type="button"
title="Absage"
class="text-red-500"
hx-get="{{ path('app_administrative_disposition_call_off', { 'uuid': disposition.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('cancel') }}
</button>
{% endif %}
{% if is_granted('DELETE', disposition) %}
<button type="button"
class="text-red-500"
@@ -139,9 +139,9 @@
</td>
{% endif %}
<td>
<span>{{ assignment.dispositions|length }}/{{ assignment.availableDispositions }}</span>
<span>{{ assignment.activeDispositions|length }}/{{ assignment.availableDispositions }}</span>
<ul>
{% for disposition in assignment.dispositions %}
{% for disposition in assignment.activeDispositions %}
<li>
<div role="button"
title="Teamer:innen-Info anzeigen"
@@ -0,0 +1,20 @@
{% extends 'htmx_modal.html.twig' %}
{% block title %}Einsatz absagen{% endblock %}
{% block content %}
{{ form_start(form) }}
<h2 class="font-bold text-lg pb-4">
Teamer {{ disposition.teamer }}
</h2>
<div class="flex flex-col space-y-4 pb-4">
{{ form_row(form.calledOffBy) }}
{{ form_row(form.calledOffReason) }}
{{ form_row(form.sendNotification) }}
</div>
<button type="submit" class="btn">
Absagen
</button>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -0,0 +1,22 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>
Hallo aus Köln,
</h1>
<p>
leider mussten wir deinen Einsatz '{{ assignment.destination }}' absagen, sodass dein Einsatz
nicht stattfinden kann:
</p>
<p>
{{ reason | nl2br }}
</p>
<p>
Bei Fragen melde dich gerne unter <a href="mailto:[email protected]">[email protected]</a>
</p>
<p>
<a href="{{ url('app_teamer_index') }}" class="button">
Zum Portal
</a>
</p>
{% endblock %}
+1
View File
@@ -12,6 +12,7 @@ label:
confirmed: bestätigt
ended: beendet
completed: abgeschlossen
called_off: abgesagt
document:
type:
invoice: Honorarnote